diff --git a/.changeset/4448-email-domain-null-only-default.md b/.changeset/4448-email-domain-null-only-default.md new file mode 100644 index 0000000000..d9dd629dc5 --- /dev/null +++ b/.changeset/4448-email-domain-null-only-default.md @@ -0,0 +1,5 @@ +--- +"adcontextprotocol": patch +--- + +`sync-email-domain-from-is-primary.ts` now classifies drift into three buckets (`null`, `www_drift`, `mismatched`) and only applies the `null` class by default. `www_drift` (the Scope3 class) is opt-in via `--include-www-drift`. `mismatched` is the subsidiary/M&A class (e.g. `linkedin.com` vs `microsoft.com`) and is never auto-fixed — those cases are surfaced for human review and are better modeled via `brands.house_domain` + `brand_domain_aliases` than by overwriting `email_domain`. Applied to prod 2026-05-12: 7 null cases backfilled. diff --git a/server/src/scripts/sync-email-domain-from-is-primary.ts b/server/src/scripts/sync-email-domain-from-is-primary.ts index 6bc790ee21..d3ca182f29 100644 --- a/server/src/scripts/sync-email-domain-from-is-primary.ts +++ b/server/src/scripts/sync-email-domain-from-is-primary.ts @@ -1,27 +1,37 @@ /** - * Reconcile `organizations.email_domain` for orgs where the value drifted - * away from `organization_domains.is_primary=true`. + * Reconcile `organizations.email_domain` for orgs where the value is NULL + * but `organization_domains.is_primary=true` already has the canonical + * domain set. * - * Root cause (pre-#4448 follow-up): the WorkOS `organization.updated` - * webhook handler sourced `email_domain` from `org.domains[0]`. WorkOS - * array order is not stable, so orgs with a verified root + a `failed` - * www variant could have WorkOS list www first, overwriting `email_domain` - * to the wrong value on every webhook fire even when our table's - * `is_primary` row was correct. + * Default mode is null-only on purpose. The broader "any drift" audit + * surfaces three classes: * - * Scope3 was the known case: `organizations.email_domain='www.scope3.com'` - * while `organization_domains.is_primary=true` was on `scope3.com`. Caused - * downstream lookups (e.g. `services/brand-enrichment.ts`'s - * `WHERE email_domain = $1`) to miss the org row entirely. + * 1. NULL email_domain, canonical present + * → strictly safe to fill. Prospects that never had a WorkOS + * `organization.updated` webhook fire (and that migration 468 missed + * because they had no organization_domains rows at the time). * - * The webhook itself is now fixed (see `routes/workos-webhooks.ts` - * `syncOrganizationDomains`) — it reads `email_domain` from - * `organization_domains.is_primary=true` directly. This script clears the - * pre-fix backlog. + * 2. email_domain = `www.` (or `` = `www.`) + * → the Scope3 class. Fixed in-flight by PR #4459's webhook patch and + * Scope3 backfilled manually. Re-running this script with + * `--include-www-drift` covers the same pattern if another org hits it + * before the webhook next fires. + * + * 3. email_domain and canonical are completely different domains (e.g. + * `linkedin.com` vs `microsoft.com`, `quattroruote.it` vs `edidomus.it`). + * → NOT a drift bug. These are subsidiary / M&A / cross-brand cases + * that need per-org human review and are likely better modeled via + * `brands.house_domain` and `brand_domain_aliases`, possibly across + * separate org records. See #4448 follow-up issue. + * + * This script defaults to class (1) only. Pass `--include-www-drift` to + * include class (2). Class (3) is never auto-fixed — flagged for review. * * Usage: - * npx tsx server/src/scripts/sync-email-domain-from-is-primary.ts # dry-run - * npx tsx server/src/scripts/sync-email-domain-from-is-primary.ts --apply # write + * npx tsx server/src/scripts/sync-email-domain-from-is-primary.ts # dry-run, nulls only + * npx tsx server/src/scripts/sync-email-domain-from-is-primary.ts --apply # apply, nulls only + * npx tsx server/src/scripts/sync-email-domain-from-is-primary.ts --include-www-drift # dry-run, nulls + www drift + * npx tsx server/src/scripts/sync-email-domain-from-is-primary.ts --include-www-drift --apply * * fly ssh console -a adcp-docs -C \ * 'node /app/dist/scripts/sync-email-domain-from-is-primary.js --apply' @@ -34,12 +44,22 @@ import { getDatabaseConfig } from '../config.js'; const apply = process.argv.includes('--apply'); const dryRun = !apply; +const includeWwwDrift = process.argv.includes('--include-www-drift'); interface DriftRow { workos_organization_id: string; org_name: string; current_email_domain: string | null; canonical_domain: string; + drift_class: 'null' | 'www_drift' | 'mismatched'; +} + +function classifyDrift(current: string | null, canonical: string): DriftRow['drift_class'] { + if (current == null || current === '') return 'null'; + const c = current.toLowerCase(); + const k = canonical.toLowerCase(); + if (c === `www.${k}` || k === `www.${c}`) return 'www_drift'; + return 'mismatched'; } async function main(): Promise { @@ -54,7 +74,7 @@ async function main(): Promise { // Only non-personal orgs — personal orgs intentionally have NULL // email_domain (see workos-webhooks.ts:587). The canonical source is the // is_primary=true row in organization_domains. - const result = await pool.query(` + const result = await pool.query>(` SELECT o.workos_organization_id, o.name AS org_name, @@ -69,19 +89,29 @@ async function main(): Promise { ORDER BY o.name `); - console.log(`=== email_domain drift reconciliation (#4448 follow-up) ===`); - console.log(`Mode: ${dryRun ? 'DRY-RUN (no writes; pass --apply to persist)' : 'APPLY'}`); - console.log(`Drifted orgs: ${result.rowCount}\n`); + const classified: DriftRow[] = result.rows.map((r) => ({ + ...r, + drift_class: classifyDrift(r.current_email_domain, r.canonical_domain), + })); - if (result.rowCount === 0) { - console.log('No drift detected — done.'); - await closeDatabase(); - return; - } + const groups = { + null: classified.filter((r) => r.drift_class === 'null'), + www_drift: classified.filter((r) => r.drift_class === 'www_drift'), + mismatched: classified.filter((r) => r.drift_class === 'mismatched'), + }; + + console.log(`=== email_domain reconciliation (#4448 follow-up) ===`); + console.log(`Mode: ${dryRun ? 'DRY-RUN' : 'APPLY'} | include-www-drift: ${includeWwwDrift}`); + console.log(`Total drifted (non-personal): ${classified.length}`); + console.log(` null (fillable) : ${groups.null.length}`); + console.log(` www_drift (Scope3 class) : ${groups.www_drift.length}`); + console.log(` mismatched (M&A / subsidiary) : ${groups.mismatched.length}\n`); - for (const row of result.rows) { + const toFix: DriftRow[] = [...groups.null, ...(includeWwwDrift ? groups.www_drift : [])]; + + for (const row of toFix) { console.log( - ` ${row.org_name} [${row.workos_organization_id}]` + ` [${row.drift_class}] ${row.org_name} [${row.workos_organization_id}]` + ` email_domain=${row.current_email_domain ?? '-'} → canonical=${row.canonical_domain}`, ); if (!dryRun) { @@ -93,10 +123,25 @@ async function main(): Promise { } } - if (dryRun) { - console.log('\nDRY-RUN — nothing written. Re-run with --apply to fix.'); + if (groups.mismatched.length > 0) { + console.log('\n--- mismatched (NEVER auto-fixed; needs human review) ---'); + for (const row of groups.mismatched) { + console.log( + ` ${row.org_name} [${row.workos_organization_id}]` + + ` email_domain=${row.current_email_domain ?? '-'} canonical=${row.canonical_domain}`, + ); + } + console.log('\nThese are likely subsidiary/M&A cases. Model via `brands.house_domain` +' + + ' `brand_domain_aliases` and (where appropriate) separate org records, rather than' + + ' overwriting email_domain. See the #4448 follow-up issue.'); + } + + if (toFix.length === 0) { + console.log('Nothing to apply in the current class selection.'); + } else if (dryRun) { + console.log(`\nDRY-RUN — nothing written. ${toFix.length} row(s) ready for --apply.`); } else { - console.log('\nApplied. Future webhook fires preserve via the fix to syncOrganizationDomains.'); + console.log(`\nApplied ${toFix.length} fix(es). Future webhook fires preserve via the syncOrganizationDomains patch.`); } await closeDatabase();