Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/4448-email-domain-null-only-default.md
Original file line number Diff line number Diff line change
@@ -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.
109 changes: 77 additions & 32 deletions server/src/scripts/sync-email-domain-from-is-primary.ts
Original file line number Diff line number Diff line change
@@ -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.<canonical>` (or `<canonical>` = `www.<email_domain>`)
* → 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'
Expand All @@ -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<void> {
Expand All @@ -54,7 +74,7 @@ async function main(): Promise<void> {
// 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<DriftRow>(`
const result = await pool.query<Omit<DriftRow, 'drift_class'>>(`
SELECT
o.workos_organization_id,
o.name AS org_name,
Expand All @@ -69,19 +89,29 @@ async function main(): Promise<void> {
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) {
Expand All @@ -93,10 +123,25 @@ async function main(): Promise<void> {
}
}

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();
Expand Down
Loading