145 lines
4.4 KiB
TypeScript
145 lines
4.4 KiB
TypeScript
|
|
/**
|
||
|
|
* Backfill `client_contacts.value_e164` from `value` for phone / whatsapp
|
||
|
|
* contacts where it's null or empty.
|
||
|
|
*
|
||
|
|
* The legacy seed (and pre-normalization production data) stored phone
|
||
|
|
* numbers in `value` as free text — "+33 4 93 00 0002" — but `value_e164`
|
||
|
|
* is what every UI surface and dedup matcher reads. This script runs the
|
||
|
|
* raw `value` through libphonenumber-js (via the script-safe wrapper to
|
||
|
|
* avoid the Node 25 metadata-loader bug) and writes the canonical E.164
|
||
|
|
* form back.
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* pnpm tsx scripts/backfill-phone-e164.ts # dry-run report
|
||
|
|
* pnpm tsx scripts/backfill-phone-e164.ts --apply # actually write
|
||
|
|
*
|
||
|
|
* The dry-run report prints, for each unparseable row, the contact id +
|
||
|
|
* raw value so you can hand-clean before re-running.
|
||
|
|
*/
|
||
|
|
import 'dotenv/config';
|
||
|
|
import { and, eq, inArray, isNull, or, sql } from 'drizzle-orm';
|
||
|
|
|
||
|
|
import { db } from '@/lib/db';
|
||
|
|
import { clientContacts } from '@/lib/db/schema/clients';
|
||
|
|
import { parsePhoneScriptSafe } from '@/lib/dedup/phone-parse';
|
||
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||
|
|
|
||
|
|
const APPLY = process.argv.includes('--apply');
|
||
|
|
|
||
|
|
interface PhoneRow {
|
||
|
|
id: string;
|
||
|
|
channel: string;
|
||
|
|
value: string | null;
|
||
|
|
valueCountry: string | null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function main() {
|
||
|
|
console.log(`Phone E.164 backfill — ${APPLY ? 'APPLY MODE' : 'dry-run'}`);
|
||
|
|
console.log('');
|
||
|
|
|
||
|
|
// Find candidate rows: phone or whatsapp contacts with a `value` set but
|
||
|
|
// `value_e164` null/empty.
|
||
|
|
const rows: PhoneRow[] = await db
|
||
|
|
.select({
|
||
|
|
id: clientContacts.id,
|
||
|
|
channel: clientContacts.channel,
|
||
|
|
value: clientContacts.value,
|
||
|
|
valueCountry: clientContacts.valueCountry,
|
||
|
|
})
|
||
|
|
.from(clientContacts)
|
||
|
|
.where(
|
||
|
|
and(
|
||
|
|
inArray(clientContacts.channel, ['phone', 'whatsapp']),
|
||
|
|
or(isNull(clientContacts.valueE164), eq(clientContacts.valueE164, '')),
|
||
|
|
sql`${clientContacts.value} IS NOT NULL AND ${clientContacts.value} <> ''`,
|
||
|
|
),
|
||
|
|
);
|
||
|
|
|
||
|
|
console.log(` found ${rows.length} candidate rows`);
|
||
|
|
|
||
|
|
let parsedFull = 0;
|
||
|
|
let parsedE164Only = 0;
|
||
|
|
let unparseable = 0;
|
||
|
|
const updates: Array<{
|
||
|
|
id: string;
|
||
|
|
valueE164: string;
|
||
|
|
valueCountry: CountryCode | null;
|
||
|
|
}> = [];
|
||
|
|
const fails: Array<{ id: string; value: string; reason: string }> = [];
|
||
|
|
|
||
|
|
for (const row of rows) {
|
||
|
|
if (!row.value) continue;
|
||
|
|
const defaultCountry = (row.valueCountry as CountryCode | null) ?? undefined;
|
||
|
|
const parsed1 = parsePhoneScriptSafe(row.value, defaultCountry);
|
||
|
|
|
||
|
|
if (parsed1.e164 && parsed1.country) {
|
||
|
|
// Both e164 + country resolved — best case.
|
||
|
|
updates.push({ id: row.id, valueE164: parsed1.e164, valueCountry: parsed1.country });
|
||
|
|
parsedFull++;
|
||
|
|
} else if (parsed1.e164) {
|
||
|
|
// E.164 came back but country didn't (e.g. UK +44 7700 900xxx
|
||
|
|
// fictional/reserved range — libphonenumber returns the e164 form
|
||
|
|
// but refuses to assign a country). Still safe to write — the e164
|
||
|
|
// is canonical. Country stays null.
|
||
|
|
updates.push({
|
||
|
|
id: row.id,
|
||
|
|
valueE164: parsed1.e164,
|
||
|
|
valueCountry: (row.valueCountry as CountryCode | null) ?? null,
|
||
|
|
});
|
||
|
|
parsedE164Only++;
|
||
|
|
} else {
|
||
|
|
fails.push({
|
||
|
|
id: row.id,
|
||
|
|
value: row.value,
|
||
|
|
reason: row.value.trim().startsWith('+')
|
||
|
|
? 'has + prefix but parse failed'
|
||
|
|
: 'no leading + and no country hint',
|
||
|
|
});
|
||
|
|
unparseable++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log('');
|
||
|
|
console.log(' ✓ parsed cleanly (e164 + country)', parsedFull);
|
||
|
|
console.log(' ✓ parsed e164 only (no country) ', parsedE164Only);
|
||
|
|
console.log(' ✗ unparseable ', unparseable);
|
||
|
|
console.log('');
|
||
|
|
|
||
|
|
if (fails.length > 0) {
|
||
|
|
console.log('Failures (first 10):');
|
||
|
|
for (const f of fails.slice(0, 10)) {
|
||
|
|
console.log(` [${f.id}] "${f.value}" — ${f.reason}`);
|
||
|
|
}
|
||
|
|
console.log('');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!APPLY) {
|
||
|
|
console.log('Dry-run only. Re-run with --apply to write the updates.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (updates.length === 0) {
|
||
|
|
console.log('No updates to write.');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(`Writing ${updates.length} updates...`);
|
||
|
|
|
||
|
|
for (const u of updates) {
|
||
|
|
await db
|
||
|
|
.update(clientContacts)
|
||
|
|
.set({
|
||
|
|
valueE164: u.valueE164,
|
||
|
|
valueCountry: u.valueCountry,
|
||
|
|
})
|
||
|
|
.where(eq(clientContacts.id, u.id));
|
||
|
|
}
|
||
|
|
|
||
|
|
console.log(` ✓ wrote ${updates.length} rows`);
|
||
|
|
}
|
||
|
|
|
||
|
|
main().catch((err) => {
|
||
|
|
console.error(err);
|
||
|
|
process.exit(1);
|
||
|
|
});
|