diff --git a/scripts/backfill-phone-e164.ts b/scripts/backfill-phone-e164.ts new file mode 100644 index 0000000..a737d08 --- /dev/null +++ b/scripts/backfill-phone-e164.ts @@ -0,0 +1,144 @@ +/** + * 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); +});