@aglyn/tenant-data-admin 1.0.0-beta.199 → 1.0.0-beta.200

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/admin-audit-write.d.ts +28 -0
  3. package/src/lib/server/admin-audit-write.js +46 -0
  4. package/src/lib/server/admin-audit-write.js.map +1 -0
  5. package/src/lib/server/admin-audit.d.ts +4 -9
  6. package/src/lib/server/admin-audit.js +12 -31
  7. package/src/lib/server/admin-audit.js.map +1 -1
  8. package/src/lib/server/consent-group-carry.js +2 -0
  9. package/src/lib/server/consent-group-carry.js.map +1 -1
  10. package/src/lib/server/contact-company-link.js +3 -0
  11. package/src/lib/server/contact-company-link.js.map +1 -1
  12. package/src/lib/server/contact-lifecycle-floor.js +3 -0
  13. package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
  14. package/src/lib/server/contact-merge.js +4 -0
  15. package/src/lib/server/contact-merge.js.map +1 -1
  16. package/src/lib/server/crm-booking-activity.js +3 -2
  17. package/src/lib/server/crm-booking-activity.js.map +1 -1
  18. package/src/lib/server/crm-records.d.ts +15 -0
  19. package/src/lib/server/crm-records.js +52 -0
  20. package/src/lib/server/crm-records.js.map +1 -1
  21. package/src/lib/server/csp-aggregate.d.ts +16 -0
  22. package/src/lib/server/csp-aggregate.js +36 -0
  23. package/src/lib/server/csp-aggregate.js.map +1 -1
  24. package/src/lib/server/duplicate-resource.js +12 -5
  25. package/src/lib/server/duplicate-resource.js.map +1 -1
  26. package/src/lib/server/email-delivery-log.d.ts +33 -0
  27. package/src/lib/server/email-delivery-log.js +43 -2
  28. package/src/lib/server/email-delivery-log.js.map +1 -1
  29. package/src/lib/server/email-suppression.d.ts +11 -14
  30. package/src/lib/server/email-suppression.js +14 -31
  31. package/src/lib/server/email-suppression.js.map +1 -1
  32. package/src/lib/server/erase-person.js +22 -3
  33. package/src/lib/server/erase-person.js.map +1 -1
  34. package/src/lib/server/erase.js +4 -3
  35. package/src/lib/server/erase.js.map +1 -1
  36. package/src/lib/server/host-memberships.d.ts +34 -0
  37. package/src/lib/server/host-memberships.js +89 -1
  38. package/src/lib/server/host-memberships.js.map +1 -1
  39. package/src/lib/server/host-visitor-records.js +23 -3
  40. package/src/lib/server/host-visitor-records.js.map +1 -1
  41. package/src/lib/server/list-members.d.ts +11 -0
  42. package/src/lib/server/list-members.js +28 -1
  43. package/src/lib/server/list-members.js.map +1 -1
  44. package/src/lib/server/list-query.d.ts +36 -0
  45. package/src/lib/server/list-query.js +49 -0
  46. package/src/lib/server/list-query.js.map +1 -0
  47. package/src/lib/server/media-tombstone.js +8 -2
  48. package/src/lib/server/media-tombstone.js.map +1 -1
  49. package/src/lib/server/org-member-list-fields.d.ts +31 -0
  50. package/src/lib/server/org-member-list-fields.js +38 -0
  51. package/src/lib/server/org-member-list-fields.js.map +1 -0
  52. package/src/lib/server/organizations.js +37 -7
  53. package/src/lib/server/organizations.js.map +1 -1
  54. package/src/lib/server/replay-erasures.js +2 -1
  55. package/src/lib/server/replay-erasures.js.map +1 -1
  56. package/src/lib/server/sso-enforcement.js +2 -1
  57. package/src/lib/server/sso-enforcement.js.map +1 -1
  58. package/src/lib/server/upsert-contact.js +8 -4
  59. package/src/lib/server/upsert-contact.js.map +1 -1
@@ -16,6 +16,7 @@
16
16
  */ import { FieldValue, Timestamp } from "firebase-admin/firestore";
17
17
  import { firebaseAdmin } from "./firebase-admin.js";
18
18
  import { eraseOrg, eraseUser } from "./erase.js";
19
+ import { addAdminAudit } from "./admin-audit-write.js";
19
20
  /**
20
21
  * How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`
21
22
  * moves them to Storage. A replay window older than this cannot be answered
@@ -187,7 +188,7 @@ import { eraseOrg, eraseUser } from "./erase.js";
187
188
  // a real run only: a plan did not replay anything, and an audit row saying
188
189
  // it did is the mistake this shape has to make impossible.
189
190
  if (!dryRun) {
190
- await firestore.collection('adminAudit').add({
191
+ await addAdminAudit(firestore, {
191
192
  actorUid,
192
193
  action: 'erasures.replayed',
193
194
  target: `restore/${new Date(sinceMs).toISOString()}`,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/replay-erasures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseOrg, eraseUser } from './erase'\n\n/**\n * How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`\n * moves them to Storage. A replay window older than this cannot be answered\n * from Firestore at all, which is the one failure mode that must not look\n * like \"nothing to replay\".\n */\nexport const AUDIT_HOT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What happened to one erasure that had to be re-applied. */\nexport interface ReplayedErasure {\n kind: 'org' | 'user'\n /** The org id or uid the original erasure destroyed. */\n id: string\n /**\n * - `replayed` — it had come back and has been erased again.\n * - `absent` — nothing was resurrected; the restore did not reach it.\n * - `blocked` — it came back and could NOT be erased again. The reason is\n * on `detail`, and this is the outcome a human has to act on.\n */\n outcome: 'replayed' | 'absent' | 'blocked'\n detail?: string\n}\n\nexport interface ReplayErasuresResult {\n /** Snapshot instant the restore was taken from — the replay's lower bound. */\n sinceMs: number\n /** True when nothing was written: a plan, not a replay. */\n dryRun: boolean\n /**\n * Set when the answer cannot be trusted. Today the only value is\n * `'audit-window'`: `sinceMs` predates the hot `adminAudit` window, so rows\n * naming erasures inside the restored span may already be in the Storage\n * archive and this run cannot see them. **An empty list under this warning\n * is not evidence that there was nothing to replay.**\n */\n incomplete?: 'audit-window'\n /** Audit rows examined. */\n examined: number\n entries: ReplayedErasure[]\n /** Convenience for the exit code — any `blocked` entry, or `incomplete`. */\n ok: boolean\n}\n\n/**\n * Re-apply every erasure a restore resurrected (AGL-1975).\n *\n * Live DPA §11 promises, in terms: *\"a deletion instruction survives any\n * restoration — data deleted at Customer's instruction and later restored from\n * a backup will be deleted again.\"* Nothing implemented it. This does.\n *\n * **The reason it is needed is specific, not theoretical.** A Firestore import\n * is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C\n * and D both say so), so importing a pre-erasure export into `(default)`\n * silently reinstates every document an erasure deleted — the whole org tree,\n * the profile, the support threads — alongside the recovery it was actually\n * run for. It happens during an incident, when nobody is reading a DPA, and\n * nothing anywhere says it happened.\n *\n * **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**\n * That is the AGL-1481 rule and this is exactly the shape it was written for:\n * a replay tool with its own copy of the sweep list would be a second\n * implementation of a cascade delete, and the last one drifted within a week —\n * so a replay would faithfully re-erase the six collections somebody\n * remembered and quietly leave the four they did not.\n *\n * ## The two things that make a naive replay fail\n *\n * 1. **`eraseOrg` refuses without a live request.** It re-reads\n * `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or\n * cancelled request can never delete. A restored org may carry no request\n * at all: if the customer asked AFTER the snapshot was taken, the\n * resurrected document predates the ask. The audit row is what closes\n * that — `after.requestedAt` records which request the original run\n * fulfilled — so the request is reinstated at its original instant before\n * the erasure is re-run. The hold is satisfied by construction: that\n * instant is in the past by at least the hold, or the first erasure would\n * not have executed either.\n * 2. **`eraseUser` refuses an org owner.** A restore can bring back both a\n * person and the workspace they owned, and `userErasureBlockers` then\n * correctly refuses to cascade. That is reported as `blocked`, never\n * forced: cascading here would delete a workspace as a side effect of\n * replaying a personal-account erasure, which is precisely the consent\n * boundary `eraseUser` exists to hold. Erase the org first — usually it\n * has its own audit row in the same list — and run this again.\n *\n * ## Why the audit window is a first-class result and not a log line\n *\n * The rows this reads are hot for 90 days before the archiver moves them to\n * Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable\n * managed backup. It does **not** cover a 90-day-old GCS export, where the\n * erasure record and the export can age out within days of each other. A\n * replay over a window that old returns an empty list for two\n * indistinguishable reasons — nothing was erased, or nothing is left to read —\n * so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty\n * list is not the same as a clean bill, and this is the one place that\n * distinction decides whether a published promise was kept.\n *\n * The query is a range on `at` alone, with the action filtered in memory\n * rather than in the query. `where('action','in',[…]).where('at','>=',…)`\n * needs a composite index that does not exist, and an erasure replay is not\n * where anybody should discover a `FAILED_PRECONDITION` — a single-field range\n * is auto-indexed everywhere, including a freshly restored database whose\n * composite indexes are still building.\n */\nexport async function replayErasuresSince(options: {\n /** The instant the restored snapshot was taken. */\n sinceMs: number\n /** Report what WOULD be re-erased and change nothing. Default true. */\n dryRun?: boolean\n /** Who is answerable, for the new audit rows. */\n actorUid?: string\n now?: number\n}): Promise<ReplayErasuresResult> {\n const {\n sinceMs,\n dryRun = true,\n actorUid = 'script:replay-erasures',\n now = Date.now(),\n } = options\n const firestore = firebaseAdmin.app().firestore()\n\n const incomplete =\n now - sinceMs > AUDIT_HOT_WINDOW_MS ? ('audit-window' as const) : undefined\n\n const rows = await firestore\n .collection('adminAudit')\n .where('at', '>=', Timestamp.fromMillis(sinceMs))\n .orderBy('at', 'asc')\n .get()\n\n const entries: ReplayedErasure[] = []\n for (const row of rows.docs) {\n const action = row.get('action') as string | undefined\n if (action !== 'org.erased' && action !== 'user.erased') continue\n const target = String(row.get('target') ?? '')\n const kind = action === 'org.erased' ? 'org' : 'user'\n const id = target.split('/')[1] ?? ''\n if (!id) continue\n\n if (kind === 'org') {\n const orgRef = firestore.collection('orgs').doc(id)\n const org = await orgRef.get()\n if (!org.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n // Which request the original run fulfilled. Without it a restored org\n // that was never asked to be erased before the snapshot is refused with\n // `no-request` — the erasure would silently not replay.\n const requestedAt = Number(row.get('after')?.requestedAt ?? 0)\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n if (requestedAt) {\n await orgRef.set(\n { erasureRequestedAt: Timestamp.fromMillis(requestedAt) },\n { merge: true },\n )\n }\n const result = await eraseOrg(id, { actorUid })\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : { kind, id, outcome: 'blocked', detail: result.skippedReason },\n )\n continue\n }\n\n // A person. `profiles/{uid}` is checked as well as `users/{uid}`: the\n // public identity is a separate top-level document (AGL-1970), so a\n // restore can bring it back on its own.\n const [user, profile] = await Promise.all([\n firestore.collection('users').doc(id).get(),\n firestore.collection('profiles').doc(id).get(),\n ])\n if (!user.exists && !profile.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n const result = await eraseUser(id)\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : {\n kind,\n id,\n outcome: 'blocked',\n // `owns-orgs` is the one a human has to act on: erase the\n // workspace first, then run this again.\n detail:\n result.skippedReason === 'owns-orgs'\n ? `owns-orgs (${(result.blockers ?? [])\n .map((blocker) => blocker.orgId)\n .join(', ')})`\n : result.skippedReason,\n },\n )\n }\n\n const blocked = entries.some((entry) => entry.outcome === 'blocked')\n\n // A record that the promise was kept — or that it could not be. Written on\n // a real run only: a plan did not replay anything, and an audit row saying\n // it did is the mistake this shape has to make impossible.\n if (!dryRun) {\n await firestore\n .collection('adminAudit')\n .add({\n actorUid,\n action: 'erasures.replayed',\n target: `restore/${new Date(sinceMs).toISOString()}`,\n before: { examined: rows.size },\n after: {\n sinceMs,\n incomplete: incomplete ?? null,\n replayed: entries.filter((e) => e.outcome === 'replayed').length,\n absent: entries.filter((e) => e.outcome === 'absent').length,\n blocked: entries.filter((e) => e.outcome === 'blocked').length,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n }\n\n return {\n sinceMs,\n dryRun,\n incomplete,\n examined: rows.size,\n entries,\n ok: !blocked && !incomplete,\n }\n}\n"],"names":["FieldValue","Timestamp","firebaseAdmin","eraseOrg","eraseUser","AUDIT_HOT_WINDOW_MS","replayErasuresSince","options","sinceMs","dryRun","actorUid","now","Date","firestore","app","incomplete","undefined","rows","collection","where","fromMillis","orderBy","get","entries","row","docs","target","result","action","String","kind","id","split","orgRef","doc","org","exists","push","outcome","requestedAt","Number","detail","set","erasureRequestedAt","merge","ok","skippedReason","user","profile","Promise","all","blockers","map","blocker","orgId","join","blocked","some","entry","add","toISOString","before","examined","size","after","replayed","filter","e","length","absent","at","serverTimestamp","catch"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/replay-erasures.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { eraseOrg, eraseUser } from './erase'\nimport { addAdminAudit } from './admin-audit-write'\n\n/**\n * How long `adminAudit` rows stay queryable before `/api/admin/audit-archive`\n * moves them to Storage. A replay window older than this cannot be answered\n * from Firestore at all, which is the one failure mode that must not look\n * like \"nothing to replay\".\n */\nexport const AUDIT_HOT_WINDOW_MS = 90 * 24 * 60 * 60 * 1000\n\n/** What happened to one erasure that had to be re-applied. */\nexport interface ReplayedErasure {\n kind: 'org' | 'user'\n /** The org id or uid the original erasure destroyed. */\n id: string\n /**\n * - `replayed` — it had come back and has been erased again.\n * - `absent` — nothing was resurrected; the restore did not reach it.\n * - `blocked` — it came back and could NOT be erased again. The reason is\n * on `detail`, and this is the outcome a human has to act on.\n */\n outcome: 'replayed' | 'absent' | 'blocked'\n detail?: string\n}\n\nexport interface ReplayErasuresResult {\n /** Snapshot instant the restore was taken from — the replay's lower bound. */\n sinceMs: number\n /** True when nothing was written: a plan, not a replay. */\n dryRun: boolean\n /**\n * Set when the answer cannot be trusted. Today the only value is\n * `'audit-window'`: `sinceMs` predates the hot `adminAudit` window, so rows\n * naming erasures inside the restored span may already be in the Storage\n * archive and this run cannot see them. **An empty list under this warning\n * is not evidence that there was nothing to replay.**\n */\n incomplete?: 'audit-window'\n /** Audit rows examined. */\n examined: number\n entries: ReplayedErasure[]\n /** Convenience for the exit code — any `blocked` entry, or `incomplete`. */\n ok: boolean\n}\n\n/**\n * Re-apply every erasure a restore resurrected (AGL-1975).\n *\n * Live DPA §11 promises, in terms: *\"a deletion instruction survives any\n * restoration — data deleted at Customer's instruction and later restored from\n * a backup will be deleted again.\"* Nothing implemented it. This does.\n *\n * **The reason it is needed is specific, not theoretical.** A Firestore import\n * is **merge-by-id, not replace** (`docs/DISASTER_RECOVERY.md` Procedures C\n * and D both say so), so importing a pre-erasure export into `(default)`\n * silently reinstates every document an erasure deleted — the whole org tree,\n * the profile, the support threads — alongside the recovery it was actually\n * run for. It happens during an incident, when nobody is reading a DPA, and\n * nothing anywhere says it happened.\n *\n * **It calls `eraseOrg`/`eraseUser` and implements no cascade of its own.**\n * That is the AGL-1481 rule and this is exactly the shape it was written for:\n * a replay tool with its own copy of the sweep list would be a second\n * implementation of a cascade delete, and the last one drifted within a week —\n * so a replay would faithfully re-erase the six collections somebody\n * remembered and quietly leave the four they did not.\n *\n * ## The two things that make a naive replay fail\n *\n * 1. **`eraseOrg` refuses without a live request.** It re-reads\n * `erasureRequestedAt` and the 7-day hold, deliberately, so a stale or\n * cancelled request can never delete. A restored org may carry no request\n * at all: if the customer asked AFTER the snapshot was taken, the\n * resurrected document predates the ask. The audit row is what closes\n * that — `after.requestedAt` records which request the original run\n * fulfilled — so the request is reinstated at its original instant before\n * the erasure is re-run. The hold is satisfied by construction: that\n * instant is in the past by at least the hold, or the first erasure would\n * not have executed either.\n * 2. **`eraseUser` refuses an org owner.** A restore can bring back both a\n * person and the workspace they owned, and `userErasureBlockers` then\n * correctly refuses to cascade. That is reported as `blocked`, never\n * forced: cascading here would delete a workspace as a side effect of\n * replaying a personal-account erasure, which is precisely the consent\n * boundary `eraseUser` exists to hold. Erase the org first — usually it\n * has its own audit row in the same list — and run this again.\n *\n * ## Why the audit window is a first-class result and not a log line\n *\n * The rows this reads are hot for 90 days before the archiver moves them to\n * Storage. That comfortably covers the 7-day PITR window and the ≤7-day usable\n * managed backup. It does **not** cover a 90-day-old GCS export, where the\n * erasure record and the export can age out within days of each other. A\n * replay over a window that old returns an empty list for two\n * indistinguishable reasons — nothing was erased, or nothing is left to read —\n * so it says `incomplete: 'audit-window'` and reports `ok: false`. An empty\n * list is not the same as a clean bill, and this is the one place that\n * distinction decides whether a published promise was kept.\n *\n * The query is a range on `at` alone, with the action filtered in memory\n * rather than in the query. `where('action','in',[…]).where('at','>=',…)`\n * needs a composite index that does not exist, and an erasure replay is not\n * where anybody should discover a `FAILED_PRECONDITION` — a single-field range\n * is auto-indexed everywhere, including a freshly restored database whose\n * composite indexes are still building.\n */\nexport async function replayErasuresSince(options: {\n /** The instant the restored snapshot was taken. */\n sinceMs: number\n /** Report what WOULD be re-erased and change nothing. Default true. */\n dryRun?: boolean\n /** Who is answerable, for the new audit rows. */\n actorUid?: string\n now?: number\n}): Promise<ReplayErasuresResult> {\n const {\n sinceMs,\n dryRun = true,\n actorUid = 'script:replay-erasures',\n now = Date.now(),\n } = options\n const firestore = firebaseAdmin.app().firestore()\n\n const incomplete =\n now - sinceMs > AUDIT_HOT_WINDOW_MS ? ('audit-window' as const) : undefined\n\n const rows = await firestore\n .collection('adminAudit')\n .where('at', '>=', Timestamp.fromMillis(sinceMs))\n .orderBy('at', 'asc')\n .get()\n\n const entries: ReplayedErasure[] = []\n for (const row of rows.docs) {\n const action = row.get('action') as string | undefined\n if (action !== 'org.erased' && action !== 'user.erased') continue\n const target = String(row.get('target') ?? '')\n const kind = action === 'org.erased' ? 'org' : 'user'\n const id = target.split('/')[1] ?? ''\n if (!id) continue\n\n if (kind === 'org') {\n const orgRef = firestore.collection('orgs').doc(id)\n const org = await orgRef.get()\n if (!org.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n // Which request the original run fulfilled. Without it a restored org\n // that was never asked to be erased before the snapshot is refused with\n // `no-request` — the erasure would silently not replay.\n const requestedAt = Number(row.get('after')?.requestedAt ?? 0)\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n if (requestedAt) {\n await orgRef.set(\n { erasureRequestedAt: Timestamp.fromMillis(requestedAt) },\n { merge: true },\n )\n }\n const result = await eraseOrg(id, { actorUid })\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : { kind, id, outcome: 'blocked', detail: result.skippedReason },\n )\n continue\n }\n\n // A person. `profiles/{uid}` is checked as well as `users/{uid}`: the\n // public identity is a separate top-level document (AGL-1970), so a\n // restore can bring it back on its own.\n const [user, profile] = await Promise.all([\n firestore.collection('users').doc(id).get(),\n firestore.collection('profiles').doc(id).get(),\n ])\n if (!user.exists && !profile.exists) {\n entries.push({ kind, id, outcome: 'absent' })\n continue\n }\n if (dryRun) {\n entries.push({\n kind,\n id,\n outcome: 'replayed',\n detail: 'would re-erase (plan)',\n })\n continue\n }\n const result = await eraseUser(id)\n entries.push(\n result.ok\n ? { kind, id, outcome: 'replayed' }\n : {\n kind,\n id,\n outcome: 'blocked',\n // `owns-orgs` is the one a human has to act on: erase the\n // workspace first, then run this again.\n detail:\n result.skippedReason === 'owns-orgs'\n ? `owns-orgs (${(result.blockers ?? [])\n .map((blocker) => blocker.orgId)\n .join(', ')})`\n : result.skippedReason,\n },\n )\n }\n\n const blocked = entries.some((entry) => entry.outcome === 'blocked')\n\n // A record that the promise was kept — or that it could not be. Written on\n // a real run only: a plan did not replay anything, and an audit row saying\n // it did is the mistake this shape has to make impossible.\n if (!dryRun) {\n await addAdminAudit(firestore, {\n actorUid,\n action: 'erasures.replayed',\n target: `restore/${new Date(sinceMs).toISOString()}`,\n before: { examined: rows.size },\n after: {\n sinceMs,\n incomplete: incomplete ?? null,\n replayed: entries.filter((e) => e.outcome === 'replayed').length,\n absent: entries.filter((e) => e.outcome === 'absent').length,\n blocked: entries.filter((e) => e.outcome === 'blocked').length,\n },\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n }\n\n return {\n sinceMs,\n dryRun,\n incomplete,\n examined: rows.size,\n entries,\n ok: !blocked && !incomplete,\n }\n}\n"],"names":["FieldValue","Timestamp","firebaseAdmin","eraseOrg","eraseUser","addAdminAudit","AUDIT_HOT_WINDOW_MS","replayErasuresSince","options","sinceMs","dryRun","actorUid","now","Date","firestore","app","incomplete","undefined","rows","collection","where","fromMillis","orderBy","get","entries","row","docs","target","result","action","String","kind","id","split","orgRef","doc","org","exists","push","outcome","requestedAt","Number","detail","set","erasureRequestedAt","merge","ok","skippedReason","user","profile","Promise","all","blockers","map","blocker","orgId","join","blocked","some","entry","toISOString","before","examined","size","after","replayed","filter","e","length","absent","at","serverTimestamp","catch"],"mappings":"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"}
@@ -58,6 +58,7 @@ import { findBreakGlassOrgOwners } from "./sso-break-glass-owners.js";
58
58
  import { invalidateTokenRevocationCache } from "./token-revocation.js";
59
59
  import firebaseAdmin from "./firebase-admin.js";
60
60
  import { notifyUsers } from "./notifications.js";
61
+ import { addAdminAudit } from "./admin-audit-write.js";
61
62
  const firestore = ()=>firebaseAdmin.app().firestore();
62
63
  /** How many tenant accounts one sweep will walk. Enterprise pools are small. */ const TENANT_USER_CAP = 1000;
63
64
  export class SsoEnforcementError extends Error {
@@ -135,7 +136,7 @@ export class SsoEnforcementError extends Error {
135
136
  // This process refuses the old token NOW (AGL-1881); every other one
136
137
  // converges within TOKEN_REVOCATION_TTL_MS.
137
138
  invalidateTokenRevocationCache(record.uid, tenantId);
138
- await db.collection('adminAudit').add({
139
+ await addAdminAudit(db, {
139
140
  actorUid,
140
141
  action: 'org.sso.enforceSignInMethods',
141
142
  target: `users/${record.uid}`,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-enforcement.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * SSO enforcement sweep (AGL-1129): when an org turns on `sso.enforced`,\n * every account in its GCIP tenant loses any sign-in method that is not the\n * org's own IdP.\n *\n * AGL-1128 stopped SSO-governed accounts from linking NEW consumer providers.\n * This is the grandfathering half: an account that linked Google before\n * enforcement keeps a way in that the customer's IdP cannot see or revoke, and\n * a bypass that predates the switch is still a bypass.\n *\n * Runs against the TENANT pool via `authForPool` — project-level Admin SDK\n * calls cannot see, let alone mutate, these accounts (AGL-1122).\n *\n * Three properties this must have, in order of how badly they bite:\n *\n * 1. **Never unlink down to zero.** An account whose only provider is the one\n * we are about to remove would be orphaned — reachable by nobody, not even\n * the IdP. Skipped and reported, never assumed away: being in the tenant\n * does not prove the SAML link is present.\n * 2. **Revoke refresh tokens for accounts it actually changed.** Unlinking\n * does not end a live session, so without this the bypass simply has a\n * clock on it. Only for accounts that changed — a sweep that revoked\n * everyone would sign out the org's whole staff on a no-op run.\n * 3. **Idempotent.** Re-running is the natural way to catch accounts created\n * between the flag flip and the sweep, so a second run must be a no-op\n * rather than a second round of revocations.\n * 4. **Never leave the ORG with no way in** (AGL-1888). Property 1 protects\n * one account at a time and is blind to the org-level failure: strip every\n * account down to the SAML link and the pool is perfectly consistent right\n * up until the IdP stops answering, at which point nobody can sign in and\n * we cannot let them back in either. The `aglyn-org-y5v14` tenant is in\n * that state today. So enforcement REFUSES unless the org keeps a way in\n * that does\n * not depend on the IdP: either a DESIGNATED break-glass account inside\n * the pool, or an org owner who lives OUTSIDE it\n * ({@link findBreakGlassOrgOwners}). The second is what makes the first\n * satisfiable at all — nothing inside a pool we provisioned can hold a\n * password, so for a while this guard could only ever say no.\n */\n\nimport type { UserRecord } from 'firebase-admin/auth'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authForPool } from './auth-pools'\nimport {\n findBreakGlassOrgOwners,\n type SsoBreakGlassOwner,\n} from './sso-break-glass-owners'\nimport { invalidateTokenRevocationCache } from './token-revocation'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyUsers } from './notifications'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** How many tenant accounts one sweep will walk. Enterprise pools are small. */\nconst TENANT_USER_CAP = 1000\n\nexport interface SsoEnforcementOptions {\n /** Staff uid recorded on the audit rows; `system` for an automated run. */\n actorUid?: string\n /** Report what would change without touching anything. */\n dryRun?: boolean\n /**\n * Run even when `sso.enforced` is false. ONLY for a rehearsal against a\n * throwaway account — the flag is the customer's instruction, and acting\n * without it removes sign-in methods nobody asked us to remove.\n */\n force?: boolean\n}\n\n/** Why an account with removable providers was left alone. */\nexport type SsoEnforcementSkip = 'would-orphan' | 'break-glass'\n\nexport interface SsoEnforcementAccount {\n uid: string\n email: string | null\n /** Providers removed (or that would be, in a dry run). */\n unlinked: string[]\n /** Providers left in place. */\n kept: string[]\n /** Why an account with removable providers was left alone. */\n skipped?: SsoEnforcementSkip\n}\n\n/**\n * Whether this org would still have a way in if its IdP stopped answering\n * (AGL-1888).\n */\nexport interface SsoLockoutAssessment {\n /**\n * True when the org keeps at least one way in that its IdP does not\n * mediate: a DESIGNATED break-glass account inside the pool, or an org\n * owner outside it.\n */\n safe: boolean\n /** Designated break-glass uids that actually retain a non-IdP credential. */\n retainedBy: string[]\n /**\n * Designated uids that do NOT protect anybody — either absent from the pool,\n * or holding nothing but the IdP link. Named rather than silently ignored:\n * a designation that protects nothing is worse than none, because it reads\n * as protection.\n */\n ineffective: string[]\n /**\n * Org owners in the PROJECT pool (AGL-1888 option (a)). The sweep cannot\n * see these accounts, so an IdP that stops answering cannot lock them out.\n * Named, not counted: the admin has to recognise the person.\n */\n ownersOutsidePool: SsoBreakGlassOwner[]\n /**\n * The owner lookup could not be completed. `ownersOutsidePool` is then\n * INCOMPLETE rather than empty, so the refusal must say we could not check\n * instead of asserting the org has nobody. Never makes an org safe.\n */\n ownerLookupFailed: boolean\n}\n\nexport interface SsoEnforcementResult {\n orgId: string\n tenantId: string\n providerId: string\n dryRun: boolean\n /** Accounts walked. */\n scanned: number\n /** Accounts whose providers changed (0 on a converged re-run). */\n changed: number\n accounts: SsoEnforcementAccount[]\n /** True when the tenant holds more accounts than one sweep walks. */\n truncated: boolean\n /**\n * Whether the org retains a way in that does not depend on its IdP\n * (AGL-1888). Present on the DRY RUN too — that is the point, since the\n * rehearsal is where an org is supposed to discover it needs one.\n */\n lockout: SsoLockoutAssessment\n}\n\nexport class SsoEnforcementError extends Error {}\n\n/**\n * Strip non-IdP sign-in methods from every account in an org's SSO tenant.\n *\n * Throws when the org has no active SSO config, or when `sso.enforced` is\n * false without `force` — enforcement is the customer's switch, and running\n * this without it would be us deciding to remove their people's sign-in\n * methods on our own.\n */\nexport async function enforceSsoSignInMethods(\n orgId: string,\n options: SsoEnforcementOptions = {},\n): Promise<SsoEnforcementResult> {\n const { actorUid = 'system', dryRun = false, force = false } = options\n const db = firestore()\n const orgSnapshot = await db.collection('orgs').doc(orgId).get()\n if (!orgSnapshot.exists) {\n throw new SsoEnforcementError(`No such organization: ${orgId}`)\n }\n const sso = orgSnapshot.get('sso') as\n | {\n tenantId?: string\n providerId?: string\n enforced?: boolean\n status?: string\n /** Accounts the org designated to keep a non-IdP way in (AGL-1888). */\n breakGlassUids?: unknown\n }\n | undefined\n const tenantId = sso?.tenantId\n const providerId = sso?.providerId\n if (!tenantId || !providerId) {\n throw new SsoEnforcementError(`${orgId} has no SSO tenant configured`)\n }\n if (sso?.status !== 'active') {\n throw new SsoEnforcementError(\n `${orgId} SSO status is \"${sso?.status ?? 'unset'}\", not active`,\n )\n }\n if (!sso?.enforced && !force) {\n throw new SsoEnforcementError(\n `${orgId} has sso.enforced = false — nothing to enforce`,\n )\n }\n\n const breakGlassUids = Array.isArray(sso?.breakGlassUids)\n ? (sso.breakGlassUids as unknown[]).map(String).filter(Boolean)\n : []\n\n const pool = authForPool(tenantId)\n const page = await pool.listUsers(TENANT_USER_CAP)\n const accounts: SsoEnforcementAccount[] = []\n const changedUids: string[] = []\n const breakGlass = new Set(breakGlassUids)\n\n for (const record of page.users) {\n const summary = planAccount(record, providerId, breakGlass)\n accounts.push(summary)\n }\n\n // The half the pool cannot supply (AGL-1888 option (a)). Run for the DRY\n // RUN too: the rehearsal is where an org learns whether it may enforce, and\n // a rehearsal that skipped this would report a lockout risk the real run\n // then disagrees with.\n const ownerLookup = await findBreakGlassOrgOwners(orgId, providerId, tenantId)\n const lockout = assessSsoLockoutRisk(\n accounts,\n providerId,\n breakGlassUids,\n ownerLookup.owners,\n ownerLookup.unavailable,\n )\n\n // THE PRE-FLIGHT (AGL-1888). Planned first, refused before a single\n // `updateUser` — this is the whole reason the plan is computed separately\n // from being carried out. Half a sweep is the worst outcome available: the\n // accounts already stripped are stripped, and the org is locked out with no\n // record of how far it got.\n //\n // The dry run is exempt, and has to be. The rehearsal is exactly how an org\n // FINDS OUT it needs a break-glass account, so refusing to rehearse would\n // leave them guessing at the requirement they are being held to.\n //\n // No override parameter. An `acknowledgeLockoutRisk: true` a caller can pass\n // is not a guard, it is a spelling of the guard being off — and the caller\n // that would pass it is the one already convinced enforcement is fine.\n if (!dryRun && !lockout.safe) {\n throw new SsoEnforcementError(SSO_LOCKOUT_REFUSAL)\n }\n\n for (const summary of accounts) {\n const record = page.users.find((user) => user.uid === summary.uid)\n if (!record) continue\n if (!summary.unlinked.length || summary.skipped) continue\n if (!dryRun) {\n await pool.updateUser(record.uid, {\n providersToUnlink: summary.unlinked,\n })\n // Only for accounts that actually changed (property 2 above). An\n // existing session outlives the unlink otherwise.\n await pool.revokeRefreshTokens(record.uid)\n // This process refuses the old token NOW (AGL-1881); every other one\n // converges within TOKEN_REVOCATION_TTL_MS.\n invalidateTokenRevocationCache(record.uid, tenantId)\n await db.collection('adminAudit').add({\n actorUid,\n action: 'org.sso.enforceSignInMethods',\n target: `users/${record.uid}`,\n before: { providers: providerIdsOf(record) },\n after: {\n providers: summary.kept,\n orgId,\n tenantId,\n unlinked: summary.unlinked,\n tokensRevoked: true,\n },\n at: FieldValue.serverTimestamp(),\n })\n }\n changedUids.push(record.uid)\n }\n\n if (!dryRun && changedUids.length) {\n // Explaining it beats a mysteriously missing button on their next visit.\n await notifyUsers(changedUids, {\n type: 'system.signInMethodRemoved',\n title: 'Your sign-in methods changed',\n body:\n 'Your organization now requires single sign-on, so other sign-in ' +\n 'methods have been removed from your account. Sign in through your ' +\n 'organization instead.',\n orgId,\n link: '/manage/user',\n })\n }\n\n return {\n orgId,\n tenantId,\n providerId,\n dryRun,\n scanned: page.users.length,\n changed: changedUids.length,\n accounts,\n truncated: Boolean(page.pageToken),\n lockout,\n }\n}\n\nconst providerIdsOf = (record: UserRecord): string[] =>\n (record.providerData ?? [])\n .map((info) => info?.providerId)\n .filter((id): id is string => Boolean(id))\n\n/**\n * What one account loses, decided without mutating anything so the dry run\n * and the real run cannot disagree.\n *\n * `password` counts as a provider to strip: an email/password credential on a\n * governed account is exactly the sort of standing bypass enforcement is\n * bought to remove, and it is not the org's IdP.\n *\n * @param breakGlass uids the org has DESIGNATED to keep their other sign-in\n * methods (AGL-1888). Deliberately a parameter rather than a lookup:\n * this function is the whole decision and stays pure, so the rehearsal\n * and the real run cannot diverge on it either.\n */\nexport function planAccount(\n record: UserRecord,\n providerId: string,\n breakGlass: ReadonlySet<string> = new Set(),\n): SsoEnforcementAccount {\n const providers = providerIdsOf(record)\n const unlinked = providers.filter((id) => id !== providerId)\n const kept = providers.filter((id) => id === providerId)\n const summary: SsoEnforcementAccount = {\n uid: record.uid,\n email: record.email ?? null,\n unlinked,\n kept,\n }\n // Property 1: an account with nothing but removable providers keeps them.\n // It is misconfigured, not a bypass to close, and orphaning it would turn a\n // security tightening into a lockout with no way back.\n if (unlinked.length && !kept.length) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'would-orphan' }\n }\n // Property 4 (AGL-1888): a designated break-glass account keeps everything.\n //\n // This IS a standing bypass of SSO enforcement, and it is meant to be —\n // that is what makes it a way back in when the IdP stops answering. The\n // alternative is the state the `aglyn-org-y5v14` tenant is in today: every\n // account in the pool holds nothing but a SAML link, and a lapsed\n // certificate or a deleted SAML app locks the organization out of itself\n // permanently.\n //\n // Checked AFTER the orphan rule so the two cannot disagree about an account\n // that is both, and it changes nothing for a break-glass account that has\n // no removable provider anyway — `skipped` marks a decision, so it is set\n // only where there was something to decide.\n if (unlinked.length && breakGlass.has(record.uid)) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'break-glass' }\n }\n return summary\n}\n\n/**\n * Would this org still have a way in after the sweep? (AGL-1888)\n *\n * Pure, and over the PLANNED accounts rather than the raw records, so it\n * answers the question about the state enforcement is actually about to\n * create rather than the one it is in now.\n *\n * ONLY A DESIGNATED ACCOUNT COUNTS. An account that happens to retain a\n * password because it is misconfigured — the `would-orphan` case, which has no\n * IdP link at all — is not break-glass: nobody chose it, it may be a stale\n * record or a service account, and an org protected by an accident it does not\n * know about is not protected. Requiring the designation is what makes this a\n * control rather than a coincidence.\n *\n * A designation only counts when the account actually holds something OTHER\n * than the org's IdP. Naming an account whose sole credential is the SAML link\n * is the most natural way to get this wrong — it looks like a break-glass\n * account and provides nothing, because it fails in exactly the situation it\n * exists for.\n *\n * THE SECOND WAY IN IS NOT IN THE POOL AT ALL (AGL-1888 option (a)). An org\n * owner holding a project-level credential is invisible to the sweep — the\n * enforcement pass lists `authForPool(tenantId)` and never touches the\n * project pool — so a lapsed certificate cannot reach them. No designation is\n * required for those: nobody has to be told to keep the key they already\n * hold, and requiring a tick would put the requirement back out of reach for\n * exactly the org that already satisfies it. Whether such an owner exists is\n * decided by {@link findBreakGlassOrgOwners}, which is where the seven\n * conditions live; this function only counts what it is handed, so that the\n * rehearsal and the real run cannot disagree.\n *\n * `ownerLookupFailed` deliberately does NOT make an org safe. It exists so a\n * refusal can distinguish \"you have nobody\" from \"we could not check\" — an\n * error swallowed into an empty list renders as a measured zero, and nothing\n * about it looks wrong.\n */\nexport function assessSsoLockoutRisk(\n accounts: readonly SsoEnforcementAccount[],\n providerId: string,\n breakGlass: readonly string[],\n ownersOutsidePool: readonly SsoBreakGlassOwner[] = [],\n ownerLookupFailed = false,\n): SsoLockoutAssessment {\n const designated = [...new Set(breakGlass.filter(Boolean))]\n const byUid = new Map(accounts.map((account) => [account.uid, account]))\n const retainedBy: string[] = []\n const ineffective: string[] = []\n for (const uid of designated) {\n const account = byUid.get(uid)\n const protects = Boolean(\n account?.kept.some((id) => id !== providerId),\n )\n if (protects) retainedBy.push(uid)\n else ineffective.push(uid)\n }\n const owners = [...ownersOutsidePool]\n return {\n safe: retainedBy.length > 0 || owners.length > 0,\n retainedBy,\n ineffective,\n ownersOutsidePool: owners,\n ownerLookupFailed,\n }\n}\n\n/**\n * The refusal an org sees when enforcing would lock it out of itself.\n *\n * Says what to do, because a refusal that only says no sends someone to turn\n * the guard off. Deliberately free of a product name — this string is read by\n * self-host operators too.\n */\nexport const SSO_LOCKOUT_REFUSAL =\n 'Enforcing single sign-on would remove every sign-in method that does not ' +\n 'go through your identity provider, and this organization would be left ' +\n 'with no way in that survives your identity provider failing. If its ' +\n 'certificate lapses or its application is removed, nobody would be able ' +\n 'to sign in and we could not let you back in either. Give the ' +\n 'organization one of two things first: an owner who signs in outside your ' +\n 'identity pool, or a designated break-glass account inside it that keeps ' +\n 'a password.'\n\nexport default enforceSsoSignInMethods\n"],"names":["FieldValue","authForPool","findBreakGlassOrgOwners","invalidateTokenRevocationCache","firebaseAdmin","notifyUsers","firestore","app","TENANT_USER_CAP","SsoEnforcementError","Error","enforceSsoSignInMethods","orgId","options","actorUid","dryRun","force","db","orgSnapshot","collection","doc","get","exists","sso","tenantId","providerId","status","enforced","breakGlassUids","Array","isArray","map","String","filter","Boolean","pool","page","listUsers","accounts","changedUids","breakGlass","Set","record","users","summary","planAccount","push","ownerLookup","lockout","assessSsoLockoutRisk","owners","unavailable","safe","SSO_LOCKOUT_REFUSAL","find","user","uid","unlinked","length","skipped","updateUser","providersToUnlink","revokeRefreshTokens","add","action","target","before","providers","providerIdsOf","after","kept","tokensRevoked","at","serverTimestamp","type","title","body","link","scanned","changed","truncated","pageToken","providerData","info","id","email","has","ownersOutsidePool","ownerLookupFailed","designated","byUid","Map","account","retainedBy","ineffective","protects","some"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/sso-enforcement.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * SSO enforcement sweep (AGL-1129): when an org turns on `sso.enforced`,\n * every account in its GCIP tenant loses any sign-in method that is not the\n * org's own IdP.\n *\n * AGL-1128 stopped SSO-governed accounts from linking NEW consumer providers.\n * This is the grandfathering half: an account that linked Google before\n * enforcement keeps a way in that the customer's IdP cannot see or revoke, and\n * a bypass that predates the switch is still a bypass.\n *\n * Runs against the TENANT pool via `authForPool` — project-level Admin SDK\n * calls cannot see, let alone mutate, these accounts (AGL-1122).\n *\n * Three properties this must have, in order of how badly they bite:\n *\n * 1. **Never unlink down to zero.** An account whose only provider is the one\n * we are about to remove would be orphaned — reachable by nobody, not even\n * the IdP. Skipped and reported, never assumed away: being in the tenant\n * does not prove the SAML link is present.\n * 2. **Revoke refresh tokens for accounts it actually changed.** Unlinking\n * does not end a live session, so without this the bypass simply has a\n * clock on it. Only for accounts that changed — a sweep that revoked\n * everyone would sign out the org's whole staff on a no-op run.\n * 3. **Idempotent.** Re-running is the natural way to catch accounts created\n * between the flag flip and the sweep, so a second run must be a no-op\n * rather than a second round of revocations.\n * 4. **Never leave the ORG with no way in** (AGL-1888). Property 1 protects\n * one account at a time and is blind to the org-level failure: strip every\n * account down to the SAML link and the pool is perfectly consistent right\n * up until the IdP stops answering, at which point nobody can sign in and\n * we cannot let them back in either. The `aglyn-org-y5v14` tenant is in\n * that state today. So enforcement REFUSES unless the org keeps a way in\n * that does\n * not depend on the IdP: either a DESIGNATED break-glass account inside\n * the pool, or an org owner who lives OUTSIDE it\n * ({@link findBreakGlassOrgOwners}). The second is what makes the first\n * satisfiable at all — nothing inside a pool we provisioned can hold a\n * password, so for a while this guard could only ever say no.\n */\n\nimport type { UserRecord } from 'firebase-admin/auth'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { authForPool } from './auth-pools'\nimport {\n findBreakGlassOrgOwners,\n type SsoBreakGlassOwner,\n} from './sso-break-glass-owners'\nimport { invalidateTokenRevocationCache } from './token-revocation'\nimport firebaseAdmin from './firebase-admin'\nimport { notifyUsers } from './notifications'\nimport { addAdminAudit } from './admin-audit-write'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/** How many tenant accounts one sweep will walk. Enterprise pools are small. */\nconst TENANT_USER_CAP = 1000\n\nexport interface SsoEnforcementOptions {\n /** Staff uid recorded on the audit rows; `system` for an automated run. */\n actorUid?: string\n /** Report what would change without touching anything. */\n dryRun?: boolean\n /**\n * Run even when `sso.enforced` is false. ONLY for a rehearsal against a\n * throwaway account — the flag is the customer's instruction, and acting\n * without it removes sign-in methods nobody asked us to remove.\n */\n force?: boolean\n}\n\n/** Why an account with removable providers was left alone. */\nexport type SsoEnforcementSkip = 'would-orphan' | 'break-glass'\n\nexport interface SsoEnforcementAccount {\n uid: string\n email: string | null\n /** Providers removed (or that would be, in a dry run). */\n unlinked: string[]\n /** Providers left in place. */\n kept: string[]\n /** Why an account with removable providers was left alone. */\n skipped?: SsoEnforcementSkip\n}\n\n/**\n * Whether this org would still have a way in if its IdP stopped answering\n * (AGL-1888).\n */\nexport interface SsoLockoutAssessment {\n /**\n * True when the org keeps at least one way in that its IdP does not\n * mediate: a DESIGNATED break-glass account inside the pool, or an org\n * owner outside it.\n */\n safe: boolean\n /** Designated break-glass uids that actually retain a non-IdP credential. */\n retainedBy: string[]\n /**\n * Designated uids that do NOT protect anybody — either absent from the pool,\n * or holding nothing but the IdP link. Named rather than silently ignored:\n * a designation that protects nothing is worse than none, because it reads\n * as protection.\n */\n ineffective: string[]\n /**\n * Org owners in the PROJECT pool (AGL-1888 option (a)). The sweep cannot\n * see these accounts, so an IdP that stops answering cannot lock them out.\n * Named, not counted: the admin has to recognise the person.\n */\n ownersOutsidePool: SsoBreakGlassOwner[]\n /**\n * The owner lookup could not be completed. `ownersOutsidePool` is then\n * INCOMPLETE rather than empty, so the refusal must say we could not check\n * instead of asserting the org has nobody. Never makes an org safe.\n */\n ownerLookupFailed: boolean\n}\n\nexport interface SsoEnforcementResult {\n orgId: string\n tenantId: string\n providerId: string\n dryRun: boolean\n /** Accounts walked. */\n scanned: number\n /** Accounts whose providers changed (0 on a converged re-run). */\n changed: number\n accounts: SsoEnforcementAccount[]\n /** True when the tenant holds more accounts than one sweep walks. */\n truncated: boolean\n /**\n * Whether the org retains a way in that does not depend on its IdP\n * (AGL-1888). Present on the DRY RUN too — that is the point, since the\n * rehearsal is where an org is supposed to discover it needs one.\n */\n lockout: SsoLockoutAssessment\n}\n\nexport class SsoEnforcementError extends Error {}\n\n/**\n * Strip non-IdP sign-in methods from every account in an org's SSO tenant.\n *\n * Throws when the org has no active SSO config, or when `sso.enforced` is\n * false without `force` — enforcement is the customer's switch, and running\n * this without it would be us deciding to remove their people's sign-in\n * methods on our own.\n */\nexport async function enforceSsoSignInMethods(\n orgId: string,\n options: SsoEnforcementOptions = {},\n): Promise<SsoEnforcementResult> {\n const { actorUid = 'system', dryRun = false, force = false } = options\n const db = firestore()\n const orgSnapshot = await db.collection('orgs').doc(orgId).get()\n if (!orgSnapshot.exists) {\n throw new SsoEnforcementError(`No such organization: ${orgId}`)\n }\n const sso = orgSnapshot.get('sso') as\n | {\n tenantId?: string\n providerId?: string\n enforced?: boolean\n status?: string\n /** Accounts the org designated to keep a non-IdP way in (AGL-1888). */\n breakGlassUids?: unknown\n }\n | undefined\n const tenantId = sso?.tenantId\n const providerId = sso?.providerId\n if (!tenantId || !providerId) {\n throw new SsoEnforcementError(`${orgId} has no SSO tenant configured`)\n }\n if (sso?.status !== 'active') {\n throw new SsoEnforcementError(\n `${orgId} SSO status is \"${sso?.status ?? 'unset'}\", not active`,\n )\n }\n if (!sso?.enforced && !force) {\n throw new SsoEnforcementError(\n `${orgId} has sso.enforced = false — nothing to enforce`,\n )\n }\n\n const breakGlassUids = Array.isArray(sso?.breakGlassUids)\n ? (sso.breakGlassUids as unknown[]).map(String).filter(Boolean)\n : []\n\n const pool = authForPool(tenantId)\n const page = await pool.listUsers(TENANT_USER_CAP)\n const accounts: SsoEnforcementAccount[] = []\n const changedUids: string[] = []\n const breakGlass = new Set(breakGlassUids)\n\n for (const record of page.users) {\n const summary = planAccount(record, providerId, breakGlass)\n accounts.push(summary)\n }\n\n // The half the pool cannot supply (AGL-1888 option (a)). Run for the DRY\n // RUN too: the rehearsal is where an org learns whether it may enforce, and\n // a rehearsal that skipped this would report a lockout risk the real run\n // then disagrees with.\n const ownerLookup = await findBreakGlassOrgOwners(orgId, providerId, tenantId)\n const lockout = assessSsoLockoutRisk(\n accounts,\n providerId,\n breakGlassUids,\n ownerLookup.owners,\n ownerLookup.unavailable,\n )\n\n // THE PRE-FLIGHT (AGL-1888). Planned first, refused before a single\n // `updateUser` — this is the whole reason the plan is computed separately\n // from being carried out. Half a sweep is the worst outcome available: the\n // accounts already stripped are stripped, and the org is locked out with no\n // record of how far it got.\n //\n // The dry run is exempt, and has to be. The rehearsal is exactly how an org\n // FINDS OUT it needs a break-glass account, so refusing to rehearse would\n // leave them guessing at the requirement they are being held to.\n //\n // No override parameter. An `acknowledgeLockoutRisk: true` a caller can pass\n // is not a guard, it is a spelling of the guard being off — and the caller\n // that would pass it is the one already convinced enforcement is fine.\n if (!dryRun && !lockout.safe) {\n throw new SsoEnforcementError(SSO_LOCKOUT_REFUSAL)\n }\n\n for (const summary of accounts) {\n const record = page.users.find((user) => user.uid === summary.uid)\n if (!record) continue\n if (!summary.unlinked.length || summary.skipped) continue\n if (!dryRun) {\n await pool.updateUser(record.uid, {\n providersToUnlink: summary.unlinked,\n })\n // Only for accounts that actually changed (property 2 above). An\n // existing session outlives the unlink otherwise.\n await pool.revokeRefreshTokens(record.uid)\n // This process refuses the old token NOW (AGL-1881); every other one\n // converges within TOKEN_REVOCATION_TTL_MS.\n invalidateTokenRevocationCache(record.uid, tenantId)\n await addAdminAudit(db, {\n actorUid,\n action: 'org.sso.enforceSignInMethods',\n target: `users/${record.uid}`,\n before: { providers: providerIdsOf(record) },\n after: {\n providers: summary.kept,\n orgId,\n tenantId,\n unlinked: summary.unlinked,\n tokensRevoked: true,\n },\n at: FieldValue.serverTimestamp(),\n })\n }\n changedUids.push(record.uid)\n }\n\n if (!dryRun && changedUids.length) {\n // Explaining it beats a mysteriously missing button on their next visit.\n await notifyUsers(changedUids, {\n type: 'system.signInMethodRemoved',\n title: 'Your sign-in methods changed',\n body:\n 'Your organization now requires single sign-on, so other sign-in ' +\n 'methods have been removed from your account. Sign in through your ' +\n 'organization instead.',\n orgId,\n link: '/manage/user',\n })\n }\n\n return {\n orgId,\n tenantId,\n providerId,\n dryRun,\n scanned: page.users.length,\n changed: changedUids.length,\n accounts,\n truncated: Boolean(page.pageToken),\n lockout,\n }\n}\n\nconst providerIdsOf = (record: UserRecord): string[] =>\n (record.providerData ?? [])\n .map((info) => info?.providerId)\n .filter((id): id is string => Boolean(id))\n\n/**\n * What one account loses, decided without mutating anything so the dry run\n * and the real run cannot disagree.\n *\n * `password` counts as a provider to strip: an email/password credential on a\n * governed account is exactly the sort of standing bypass enforcement is\n * bought to remove, and it is not the org's IdP.\n *\n * @param breakGlass uids the org has DESIGNATED to keep their other sign-in\n * methods (AGL-1888). Deliberately a parameter rather than a lookup:\n * this function is the whole decision and stays pure, so the rehearsal\n * and the real run cannot diverge on it either.\n */\nexport function planAccount(\n record: UserRecord,\n providerId: string,\n breakGlass: ReadonlySet<string> = new Set(),\n): SsoEnforcementAccount {\n const providers = providerIdsOf(record)\n const unlinked = providers.filter((id) => id !== providerId)\n const kept = providers.filter((id) => id === providerId)\n const summary: SsoEnforcementAccount = {\n uid: record.uid,\n email: record.email ?? null,\n unlinked,\n kept,\n }\n // Property 1: an account with nothing but removable providers keeps them.\n // It is misconfigured, not a bypass to close, and orphaning it would turn a\n // security tightening into a lockout with no way back.\n if (unlinked.length && !kept.length) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'would-orphan' }\n }\n // Property 4 (AGL-1888): a designated break-glass account keeps everything.\n //\n // This IS a standing bypass of SSO enforcement, and it is meant to be —\n // that is what makes it a way back in when the IdP stops answering. The\n // alternative is the state the `aglyn-org-y5v14` tenant is in today: every\n // account in the pool holds nothing but a SAML link, and a lapsed\n // certificate or a deleted SAML app locks the organization out of itself\n // permanently.\n //\n // Checked AFTER the orphan rule so the two cannot disagree about an account\n // that is both, and it changes nothing for a break-glass account that has\n // no removable provider anyway — `skipped` marks a decision, so it is set\n // only where there was something to decide.\n if (unlinked.length && breakGlass.has(record.uid)) {\n return { ...summary, unlinked: [], kept: providers, skipped: 'break-glass' }\n }\n return summary\n}\n\n/**\n * Would this org still have a way in after the sweep? (AGL-1888)\n *\n * Pure, and over the PLANNED accounts rather than the raw records, so it\n * answers the question about the state enforcement is actually about to\n * create rather than the one it is in now.\n *\n * ONLY A DESIGNATED ACCOUNT COUNTS. An account that happens to retain a\n * password because it is misconfigured — the `would-orphan` case, which has no\n * IdP link at all — is not break-glass: nobody chose it, it may be a stale\n * record or a service account, and an org protected by an accident it does not\n * know about is not protected. Requiring the designation is what makes this a\n * control rather than a coincidence.\n *\n * A designation only counts when the account actually holds something OTHER\n * than the org's IdP. Naming an account whose sole credential is the SAML link\n * is the most natural way to get this wrong — it looks like a break-glass\n * account and provides nothing, because it fails in exactly the situation it\n * exists for.\n *\n * THE SECOND WAY IN IS NOT IN THE POOL AT ALL (AGL-1888 option (a)). An org\n * owner holding a project-level credential is invisible to the sweep — the\n * enforcement pass lists `authForPool(tenantId)` and never touches the\n * project pool — so a lapsed certificate cannot reach them. No designation is\n * required for those: nobody has to be told to keep the key they already\n * hold, and requiring a tick would put the requirement back out of reach for\n * exactly the org that already satisfies it. Whether such an owner exists is\n * decided by {@link findBreakGlassOrgOwners}, which is where the seven\n * conditions live; this function only counts what it is handed, so that the\n * rehearsal and the real run cannot disagree.\n *\n * `ownerLookupFailed` deliberately does NOT make an org safe. It exists so a\n * refusal can distinguish \"you have nobody\" from \"we could not check\" — an\n * error swallowed into an empty list renders as a measured zero, and nothing\n * about it looks wrong.\n */\nexport function assessSsoLockoutRisk(\n accounts: readonly SsoEnforcementAccount[],\n providerId: string,\n breakGlass: readonly string[],\n ownersOutsidePool: readonly SsoBreakGlassOwner[] = [],\n ownerLookupFailed = false,\n): SsoLockoutAssessment {\n const designated = [...new Set(breakGlass.filter(Boolean))]\n const byUid = new Map(accounts.map((account) => [account.uid, account]))\n const retainedBy: string[] = []\n const ineffective: string[] = []\n for (const uid of designated) {\n const account = byUid.get(uid)\n const protects = Boolean(\n account?.kept.some((id) => id !== providerId),\n )\n if (protects) retainedBy.push(uid)\n else ineffective.push(uid)\n }\n const owners = [...ownersOutsidePool]\n return {\n safe: retainedBy.length > 0 || owners.length > 0,\n retainedBy,\n ineffective,\n ownersOutsidePool: owners,\n ownerLookupFailed,\n }\n}\n\n/**\n * The refusal an org sees when enforcing would lock it out of itself.\n *\n * Says what to do, because a refusal that only says no sends someone to turn\n * the guard off. Deliberately free of a product name — this string is read by\n * self-host operators too.\n */\nexport const SSO_LOCKOUT_REFUSAL =\n 'Enforcing single sign-on would remove every sign-in method that does not ' +\n 'go through your identity provider, and this organization would be left ' +\n 'with no way in that survives your identity provider failing. If its ' +\n 'certificate lapses or its application is removed, nobody would be able ' +\n 'to sign in and we could not let you back in either. Give the ' +\n 'organization one of two things first: an owner who signs in outside your ' +\n 'identity pool, or a designated break-glass account inside it that keeps ' +\n 'a password.'\n\nexport default enforceSsoSignInMethods\n"],"names":["FieldValue","authForPool","findBreakGlassOrgOwners","invalidateTokenRevocationCache","firebaseAdmin","notifyUsers","addAdminAudit","firestore","app","TENANT_USER_CAP","SsoEnforcementError","Error","enforceSsoSignInMethods","orgId","options","actorUid","dryRun","force","db","orgSnapshot","collection","doc","get","exists","sso","tenantId","providerId","status","enforced","breakGlassUids","Array","isArray","map","String","filter","Boolean","pool","page","listUsers","accounts","changedUids","breakGlass","Set","record","users","summary","planAccount","push","ownerLookup","lockout","assessSsoLockoutRisk","owners","unavailable","safe","SSO_LOCKOUT_REFUSAL","find","user","uid","unlinked","length","skipped","updateUser","providersToUnlink","revokeRefreshTokens","action","target","before","providers","providerIdsOf","after","kept","tokensRevoked","at","serverTimestamp","type","title","body","link","scanned","changed","truncated","pageToken","providerData","info","id","email","has","ownersOutsidePool","ownerLookupFailed","designated","byUid","Map","account","retainedBy","ineffective","protects","some"],"mappings":"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"}
@@ -17,7 +17,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  */ import { CAPTURED_BY_HOST_FIELD, checkCrmRecordsQuota, consentGroupScope, CONTACT_FACETS_FIELD, CONTACT_FORM_IDS_CAP, CONTACT_FORM_IDS_FIELD, marketingConsentFieldsForGroup, mergeContactInteraction, normalizeCampaignIds, readContactFacet, normalizeContactEmail, ORG_SCOPE_TOKEN } from "@aglyn/aglyn/server";
18
18
  import { FieldValue } from "firebase-admin/firestore";
19
19
  import { firebaseAdmin } from "./firebase-admin.js";
20
- import { countCrmRecords } from "./crm-records.js";
20
+ import { countCrmRecords, restampCrmListFieldsAt } from "./crm-records.js";
21
21
  import { attributeOrderToEmail } from "./email-revenue-attribution.js";
22
22
  import { hostRefusesCaptureForErasure } from "./email-suppression.js";
23
23
  import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
@@ -30,7 +30,7 @@ import { declineMarketingConsentFields, MARKETING_CONSENT_SOURCE_FIELD } from "@
30
30
  * substitutes a fixture barrel for, and a fixture that has to re-export the
31
31
  * whole of `@aglyn/aglyn` to keep a normalizer reachable is a fixture that
32
32
  * drifts. A direct path is real in every harness.
33
- */ import { advanceContactLifecycleStage, CONTACT_COMPANY_IDS_FIELD, CONTACT_FIELD_KEY_PATTERN, CRM_COLLECTIONS, isContactLifecycleStage, planContactCompanyLink, readContactCompanyLink } from "@aglyn/aglyn/app-utils/crm";
33
+ */ import { advanceContactLifecycleStage, CONTACT_COMPANY_IDS_FIELD, CONTACT_FIELD_KEY_PATTERN, CRM_COLLECTIONS, crmNewRecordListFields, isContactLifecycleStage, planContactCompanyLink, readContactCompanyLink } from "@aglyn/aglyn/app-utils/crm";
34
34
  import { normalizeAddress, normalizePhone } from "@aglyn/aglyn/foundation/definitions/contact.types";
35
35
  import { contactCompanyMirrorValue, settleCompanyContactsCounts } from "./contact-company-link.js";
36
36
  import { emailIndexBeside, findContactByEmail, writeContactEmailIndex } from "./contact-email-index.js";
@@ -362,6 +362,9 @@ export async function upsertHostContact(options) {
362
362
  merge: true
363
363
  });
364
364
  await settleCompanyContactsCounts(companiesBeside(contactsRef), link);
365
+ // The merged facet, name and scope are what the Contacts list
366
+ // searches and filters by (AGL-3321): restamped from the stored row.
367
+ await restampCrmListFieldsAt(docSnapshot.ref, 'contacts');
365
368
  return {
366
369
  contactId: docSnapshot.id,
367
370
  created: false
@@ -420,7 +423,7 @@ export async function upsertHostContact(options) {
420
423
  const advanced = advanceContactLifecycleStage(profile.lifecycleStage, options.initialLifecycleStage);
421
424
  if (advanced) profile.lifecycleStage = advanced;
422
425
  if (profile.address === null) delete profile.address;
423
- const created = await contactsRef.add(_extends({
426
+ const record = _extends({
424
427
  hostId: options.hostId,
425
428
  /*
426
429
  * WHICH SITES HAVE MET THIS PERSON — attribution, and separate from
@@ -489,7 +492,8 @@ export async function upsertHostContact(options) {
489
492
  }, consentFields, {
490
493
  createdAt: FieldValue.serverTimestamp(),
491
494
  updatedAt: FieldValue.serverTimestamp()
492
- }));
495
+ });
496
+ const created = await contactsRef.add(_extends({}, record, crmNewRecordListFields('contacts', record)));
493
497
  // The address now resolves to this row (AGL-2625). Its own catch inside
494
498
  // the writer, so an index the capture could not reach costs nothing.
495
499
  await writeContactEmailIndex(emailIndexBeside(contactsRef), created.id, [
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/upsert-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynPostalAddress,\n CAPTURED_BY_HOST_FIELD,\n checkCrmRecordsQuota,\n consentGroupScope,\n CONTACT_FACETS_FIELD,\n CONTACT_FORM_IDS_CAP,\n CONTACT_FORM_IDS_FIELD,\n type ContactFacet,\n type ContactInteraction,\n type ContactLifecycleStage,\n type ContactSource,\n marketingConsentFieldsForGroup,\n mergeContactInteraction,\n normalizeCampaignIds,\n readContactFacet,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { countCrmRecords } from './crm-records'\nimport { attributeOrderToEmail } from './email-revenue-attribution'\nimport { hostRefusesCaptureForErasure } from './email-suppression'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n declineMarketingConsentFields,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\n/*\n * The module paths, like `name-search` above, rather than the barrel: the\n * pure helpers this door leans on are exactly the ones a spec of the door\n * substitutes a fixture barrel for, and a fixture that has to re-export the\n * whole of `@aglyn/aglyn` to keep a normalizer reachable is a fixture that\n * drifts. A direct path is real in every harness.\n */\nimport {\n advanceContactLifecycleStage,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_FIELD_KEY_PATTERN,\n type ContactCustomValue,\n CRM_COLLECTIONS,\n isContactLifecycleStage,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n contactCompanyMirrorValue,\n settleCompanyContactsCounts,\n} from './contact-company-link'\nimport {\n emailIndexBeside,\n findContactByEmail,\n writeContactEmailIndex,\n} from './contact-email-index'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\n\n/**\n * What an upsert did, for the callers that need to know (AGL-2602).\n *\n * The capture doors never look: a form submission or an order must succeed\n * whatever happened to the CRM record, which is why this function swallows\n * its own errors and why every existing caller `await`s it for its side\n * effect alone. An IMPORT is the caller that has to know, row by row —\n * \"created\" and \"merged\" are its two headline numbers, and a row the\n * audience band refused has to be handed back to the operator as a row\n * rather than becoming one more tick on a counter nobody reconciles against\n * a file. A verdict is returned rather than thrown so the swallowing stays:\n * `refused: 'error'` is the same silence the doors have always had, now with\n * a name.\n */\nexport type UpsertHostContactVerdict =\n | {\n contactId: string\n /** True when this call created the row; false when it merged into one. */\n created: boolean\n }\n | {\n /**\n * `erased`: the site holds an erasure row for the address (AGL-2623),\n * so no record is created — a capture must not quietly rebuild a\n * person the workspace erased. Only a CREATE is refused; a merge\n * cannot arise, because the erasure removed the row it would merge\n * into.\n */\n refused: 'invalid-email' | 'band' | 'erased' | 'error'\n }\n\n/**\n * The per-holder profile fields a door may write alongside the identity.\n *\n * `Pick`ed from the facet rather than typed afresh so the two cannot drift:\n * a field the facet grows is a field this option may carry the moment the\n * pick names it, and one it does not name is refused at compile time.\n */\nexport type UpsertHostContactFacet = Partial<\n Pick<\n ContactFacet,\n | 'phone'\n | 'jobTitle'\n | 'leadSource'\n | 'companyId'\n | 'companyName'\n | 'address'\n | 'ownerUid'\n | 'lifecycleStage'\n | 'custom'\n >\n>\n\n/**\n * The profile fields a door may hand this function (AGL-2596): the parts of\n * a person's record that no capture surface collects — the console's create\n * drawer and the import do, and the order door adds the stage. `custom` is\n * the holder's own field values, keyed by `ContactFieldDefinition.key`; the\n * import maps spreadsheet columns onto them, and the definitions live under\n * the same group the values are written to.\n */\nexport type ContactProfileInput = UpsertHostContactFacet\n\n/**\n * The profile as it may be STORED: every value normalized, every unusable\n * one dropped, and nothing present that was not given.\n *\n * Only the keys given come back, which is what lets a merge write this\n * straight into the facet: a door that knows the phone number and nothing\n * else leaves the title, the owner and the stage exactly as another door\n * left them. An address given as `null` is a deliberate clearing and is kept\n * as `null`; one that normalizes to nothing is the same thing.\n */\nfunction storableProfile(input: ContactProfileInput | undefined): {\n phone?: string\n jobTitle?: string\n leadSource?: string\n address?: ReturnType<typeof normalizeAddress>\n companyId?: string\n companyName?: string\n ownerUid?: string\n lifecycleStage?: ContactFacet['lifecycleStage']\n custom?: Record<string, ContactCustomValue>\n} {\n if (!input) return {}\n const out: ReturnType<typeof storableProfile> = {}\n if (input.phone !== undefined) {\n const phone = normalizePhone(input.phone)\n if (phone) out.phone = phone\n }\n if (typeof input.jobTitle === 'string') {\n const jobTitle = input.jobTitle.trim().slice(0, 120)\n if (jobTitle) out.jobTitle = jobTitle\n }\n // The lead's value, carried on conversion (AGL-3298). Judged against the\n // org's list by the door that took it; held here to the label's shape.\n if (typeof input.leadSource === 'string') {\n const leadSource = input.leadSource.trim().replace(/\\s+/g, ' ').slice(0, 120)\n if (leadSource) out.leadSource = leadSource\n }\n if (input.address !== undefined) out.address = normalizeAddress(input.address)\n if (typeof input.companyId === 'string' && input.companyId.trim()) {\n out.companyId = input.companyId.trim().slice(0, 128)\n }\n // The name as this holder knows it, beside the link — the merge fields\n // read `facet.companyName`, and a link with no name renders as nothing.\n if (typeof input.companyName === 'string' && input.companyName.trim()) {\n out.companyName = input.companyName.trim().slice(0, 120)\n }\n if (typeof input.ownerUid === 'string' && input.ownerUid.trim()) {\n out.ownerUid = input.ownerUid.trim().slice(0, 128)\n }\n if (isContactLifecycleStage(input.lifecycleStage)) {\n out.lifecycleStage = input.lifecycleStage\n }\n if (input.custom && typeof input.custom === 'object') {\n /*\n * Only a key a field definition could have, and only a value the field\n * types can hold. A nested object here is a map the merge below would\n * write as a subtree nobody can render; a key with a dot in it would be\n * read as a PATH by the next dotted update to touch the facet. Nothing is\n * coerced — a door that has a number should send one — and an empty map\n * is left off rather than written as `{}` over a holder's values.\n */\n const custom: Record<string, ContactCustomValue> = {}\n for (const [key, value] of Object.entries(input.custom)) {\n if (!CONTACT_FIELD_KEY_PATTERN.test(key)) continue\n if (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n custom[key] = typeof value === 'string' ? value.slice(0, 2000) : value\n }\n }\n if (Object.keys(custom).length) out.custom = custom\n }\n return out\n}\n\n/**\n * The org's companies collection, beside its contacts one.\n *\n * Reached through the contacts reference's parent — the org document —\n * because that is the one handle this function holds on the org. `parent`\n * is `null` only for a root collection, which an org subcollection never\n * is; answered as `null` rather than thrown so a count that has nowhere to\n * land is skipped, and never costs the capture.\n */\nfunction companiesBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n const parent = contactsRef.parent\n return parent ? parent.collection(CRM_COLLECTIONS.companies) : null\n}\n\n/**\n * Tags as the profile drawer stores them: trimmed, lowercased, deduplicated\n * and capped at twenty — so an imported `VIP` and a typed `vip` are one tag.\n */\nfunction normalizeTags(tags: readonly string[] | undefined): string[] {\n return [\n ...new Set(\n (tags ?? [])\n .map((tag) => String(tag ?? '').trim().toLowerCase())\n .filter(Boolean),\n ),\n ].slice(0, 20)\n}\n\n/**\n * Contacts ingestion (AGL-197): upserts an org-scoped contact doc (AGL-237)\n * keyed by normalized email from any capture point (forms, membership,\n * orders, bookings). Fire-and-forget by design — callers should never\n * fail their primary write because contact capture had a problem.\n *\n * Quota (AGL-890): contacts are audience BANDS, not hard caps. Paid plans\n * always create — contacts past the included band meter onto the monthly\n * invoice (report-usage cron). Free hard-bands at the included count:\n * only there do dropped creations increment `counters/contactsDropped`,\n * surfaced as a console alert (AGL-891). Interactions on existing\n * contacts always append regardless of plan.\n */\n/**\n * What a capture door learns when its capture made a NEW person.\n *\n * Handed to {@link UpsertHostContactOptions.onCreated} once, on the create\n * branch only. The merge branch is a visit by somebody the org already held,\n * which is another interaction and not a new contact — the same line\n * `campaignTouch` draws for attribution. Scalars and one string array, so\n * the runtime can flatten it into an event payload without inventing keys.\n */\nexport interface HostContactCreated {\n contactId: string\n hostId: string\n email: string\n name?: string\n source: ContactSource\n /** The capture surface's campaigns, normalized — `[]` when it had none. */\n campaignIds: string[]\n /**\n * The stage the create wrote onto the capturing facet — the door's\n * {@link UpsertHostContactOptions.initialLifecycleStage}, or the profile's\n * own — and absent when the capture named none, so an automation can\n * filter `lifecycleStage == \"lead\"` on the day the person appears.\n */\n lifecycleStage?: ContactLifecycleStage\n}\n\nexport interface UpsertHostContactOptions {\n hostId: string\n email: unknown\n name?: string\n source: ContactSource\n interaction: Omit<ContactInteraction, 'type' | 'atMs'> & { atMs?: number }\n /**\n * Explicit marketing opt-in (AGL-301) with a consent timestamp, recorded\n * against {@link hostId} — the brand whose form carried the checkbox — and\n * against the rest of its consent group only as far as\n * {@link disclosedConsentGroup} proves the group was shown.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` of the sentence the capture surface\n * rendered beside the checkbox (AGL-3320), as the surface sent it back.\n *\n * The grant pools across the site's consent group only when this is the\n * group's CURRENT key; absent, stale or mistyped, it is recorded for\n * {@link hostId} alone. Every surface that renders no disclosure passes\n * none, so its grant is one site's however the org is grouped. Only the\n * grant narrows: the record's `visibleTo` and its holder are the group's\n * either way, because who may see a person is not who may mail them.\n */\n disclosedConsentGroup?: string | null\n /**\n * An explicit REFUSAL, recorded against {@link hostId} (AGL-3185).\n *\n * Its own flag rather than `marketingConsent: false`, because every door\n * that passes a checkbox's value passes `false` for a box left alone, and\n * a box left alone records nothing — absence is the third state, and\n * turning it into a refusal would silently make every unticked visitor\n * unmailable. A door sets this only for an act of refusal: a switch turned\n * off, a prompt answered no. Ignored when {@link marketingConsent} is true.\n */\n declineMarketingConsent?: boolean\n /**\n * The provenance stored on the consent entry {@link marketingConsent} or\n * {@link declineMarketingConsent} writes (AGL-3185): who recorded it, which\n * door, which wording version. The console doors pass the person's own\n * (`actor: 'person'`); a site's capture surface passes none, which the\n * reader takes as the person's own act anyway.\n */\n marketingConsentSource?: MarketingConsentSource\n /**\n * Order value in cents — rolls into RFM fields (AGL-328).\n *\n * WHAT IT COUNTS (AGL-1748). GROSS of the platform fee and GROSS of\n * refunds — the money the customer handed over, not the money the merchant\n * kept. Every writer passes the same thing: whatever was actually charged\n * (`amount_total` for a Stripe path, `totals.totalCents` for POS), never a\n * figure re-derived from product docs, which is the AGL-1698/AGL-1711\n * lesson. Gross of the fee because this is a CUSTOMER attribute answering\n * \"what is this person worth to me?\", and the fee is a cost of the channel,\n * not something the buyer failed to spend.\n *\n * Refunds are still NOT netted here, and now they are recorded elsewhere\n * (AGL-1754). `refund.ts` writes `refundedCents`, `refundedOrdersCount` and\n * `lastRefundAtMs` BESIDE these fields — the shape AGL-1747 chose for the\n * same question on the orders CSV — rather than decrementing a stored number\n * whose meaning would then differ between rows written before and after that\n * commit. So `ltvCents` and `ordersCount` remain gross by definition, and a\n * READER that wants the net computes `ltvCents - refundedCents`, clamping\n * only what it ranks on: the difference can be negative for a customer whose\n * pre-AGL-1748 purchase was never counted and whose refund was, which is a\n * missing purchase showing itself rather than a corrupt contact. AGL-1753 is\n * the backfill that reconciles it. See `contact-refund.ts` in the commerce\n * plugin for the full reasoning and for why a refund never CREATES a contact.\n *\n * Passing 0 or omitting it means \"no purchase\": `ltvCents`, `ordersCount`,\n * `lastPurchaseAtMs` and `firstPurchaseAtMs` are all left untouched, which\n * is why a caller that formats the amount into the interaction summary and\n * forgets this field records a customer who has apparently never bought\n * anything.\n */\n purchaseCents?: number\n /**\n * The currency {@link purchaseCents} is in, lowercase, when the door knows.\n *\n * Absent everywhere today, because no order document carries a currency and\n * every checkout door writes `currency: 'usd'` onto the Stripe line items.\n * `attributeOrderToEmail` defaults it on that basis and says so. The field\n * exists so a door that ever charges in something else can pass it, and the\n * campaign revenue report keeps it in its own bucket rather than adding it\n * to the dollars.\n */\n purchaseCurrency?: string\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * ⛔ The ORDER path passes none, and must not start. An order already has\n * its own join one branch below — `attributeOrderToEmail`, keyed on the\n * order id — and a second record for the same sale would be the same money\n * counted twice under two rules. This is the door for the moments an order\n * does NOT cover: a form submission, a membership sign-up, a booking, a\n * newsletter capture.\n *\n * Resolved rather than raw, for the reason `addHostLead` states: one\n * visitor action reaches several writers and the touch lookup is paid once.\n */\n campaignTouch?: ResolvedCampaignTouch | null\n /**\n * The campaigns the CAPTURE SURFACE is filed under.\n *\n * ⚠️ A different fact from {@link campaignTouch} beside it, and the two must\n * never be folded together. A touch is where the visitor came FROM — an ad,\n * a link, a browser-supplied label resolved through an allowlist. This is\n * which campaigns the merchant put the form itself in, which is the\n * merchant's own act and is true of everybody who fills that form in,\n * including the visitor who arrived by typing the address.\n *\n * ⛔ And it is not consent. Filing a form under a campaign says nothing\n * about what the person agreed to; `marketingConsent` above is the only\n * input that records a basis.\n */\n campaignIds?: readonly string[]\n /**\n * Tags to put on THIS holder's facet (AGL-2602).\n *\n * Added to, never replaced, on a merge: a person the merchant tagged by\n * hand and later imported keeps the hand-written tag beside the file's.\n */\n tags?: readonly string[]\n /**\n * The profile a door knows about the person — phone, title, company,\n * address, owner, stage, custom values — written into THIS holder's facet\n * (AGL-2602). Only the keys present are written, so a door that knows the\n * phone and nothing else does not blank the title somebody typed.\n */\n facet?: UpsertHostContactFacet\n /**\n * The EARLIEST stage that describes what this capture was (AGL-2612).\n *\n * Every door names one: a form submission is a `lead`, a newsletter opt-in\n * or a member sign-up is a `subscriber`, a purchase — an order, a paid\n * booking — is a `customer`. The rule applied to it is\n * `advanceContactLifecycleStage`: it fills an empty stage and advances an\n * earlier one, and never moves anybody back, so a customer who fills in\n * the contact form is still a customer and a subscriber who then submits\n * a form becomes a lead. Applied on top of `facet.lifecycleStage` when a\n * door carries both — the caller's stage is the base and this is its\n * floor.\n *\n * A FLOOR and not a value, which is why it is not the facet field: the\n * facet's `lifecycleStage` is a SET, the shape the console's create\n * drawer and the import need — the merchant typed a stage and that is the\n * stage — and a capture door writing a set would put every returning\n * customer back to `lead` on their next enquiry. Omitted by the doors\n * that carry the caller's own stage or none (manual, import, API, a lead\n * conversion), whose contacts read as \"no stage\", which is true.\n */\n initialLifecycleStage?: ContactLifecycleStage\n /**\n * Told when this capture created a contact (AGL-2605).\n *\n * A HOOK rather than an event emitted from here, and the reason is the\n * dependency direction: the event fan-out lives in `libs/tenant/runtime`,\n * which imports THIS library for its Firestore handle and its org helpers.\n * An import back up from here would be a cycle, and the module boundaries\n * (`scope:data` may depend on data and util only) refuse it besides. So\n * this module reports the fact and the runtime's `captureHostContact`\n * turns it into `contactCreated` — every server door goes through that\n * wrapper, and a door that calls this function directly has chosen to\n * create contacts nothing can react to.\n *\n * Awaited with its own catch, like the order join above: the least\n * important write on the path, and a failure in it must not cost the\n * capture that already happened.\n */\n onCreated?: (created: HostContactCreated) => void | Promise<void>\n}\n\nexport async function upsertHostContact(\n options: UpsertHostContactOptions,\n): Promise<UpsertHostContactVerdict> {\n try {\n const email = normalizeContactEmail(options.email)\n if (!email) return { refused: 'invalid-email' }\n const tags = normalizeTags(options.tags)\n\n /*==========================================\n * THE PURCHASE DOOR, AND THEREFORE THE ATTRIBUTION DOOR.\n *\n * Every way of buying something in this product — the cart, buy-now, the\n * POS register, a draft order, a reservation, a subscription renewal, a\n * booking — announces itself here, in exactly one shape: source `order`,\n * a `purchaseCents` amount, and a `refId` naming what was bought. That\n * shape IS the purchase chokepoint, which is why the revenue join hangs\n * off it rather than off seven call sites in a webhook.\n *\n * ABOVE the audience-band gate below, and deliberately. Contact creation\n * is band gated, so a Free org past its included count drops the CRM\n * record — and an attribution written inside that branch would drop the\n * revenue with it. The join keys on the address hash, exactly as the\n * touch and the suppression list do, so it never needs a contact document\n * to exist: a guest checkout by somebody who is not and never becomes a\n * contact still credits the campaign whose link they clicked.\n *\n * Its own `catch`, inside a function that already swallows: this is the\n * least important write on the path, and a failure here must not cost the\n * contact capture below it.\n *=========================================*/\n if (options.source === 'order' && options.interaction.refId) {\n await attributeOrderToEmail({\n hostId: options.hostId,\n orderId: String(options.interaction.refId),\n email,\n amountCents: Number(options.purchaseCents ?? 0),\n ...(options.purchaseCurrency\n ? { currency: options.purchaseCurrency }\n : {}),\n orderedAtMs: options.interaction.atMs ?? Date.now(),\n }).catch(() => null)\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(options.hostId)\n // Contacts are org-scoped (AGL-237): every host in the org feeds one\n // shared list.\n const contactsRef = await orgDataCollectionForHost(\n options.hostId,\n 'contacts',\n )\n /*\n * The consent group this capture belongs to — the sites declared to be\n * one sender, or this site alone. Resolved once and used for three\n * different decisions below, which must all agree: which controller the\n * basis is recorded for, which sites the row becomes visible to, and\n * which holder the profile is filed under.\n */\n const group = await consentGroupForSite(options.hostId)\n /*\n * THE CONSENT ENTRY THIS CAPTURE WRITES, decided once for both branches.\n *\n * A grant outranks a refusal flag set beside it — one door cannot mean\n * both — and neither writes anything when the door carried no act, so\n * an unticked box stays the third state. The provenance rides inside the\n * per-host entry, where the reader looks for it, never at the top of a\n * document every brand in the org shares.\n *\n * The grant covers the group only when the capture surface proved it\n * showed the group's current disclosure — `consentGroupForGrant` answers\n * the site alone for every other capture. The visibility and the holder\n * below keep the whole group.\n */\n const consentExtra = options.marketingConsentSource\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: options.marketingConsentSource }\n : undefined\n const consentFields = options.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, options.disclosedConsentGroup),\n Date.now(),\n consentExtra,\n )\n : options.declineMarketingConsent\n ? declineMarketingConsentFields(options.hostId, Date.now(), consentExtra)\n : {}\n const interaction: ContactInteraction = {\n type: options.source,\n atMs: options.interaction.atMs ?? Date.now(),\n // WHICH SITE this visit happened on. The row is shared; the history on\n // it is not, and a timeline with no site cannot be split for an\n // agency's client without showing them another client's activity.\n hostId: options.hostId,\n ...(options.interaction.refId\n ? { refId: options.interaction.refId }\n : {}),\n ...(options.interaction.summary\n ? { summary: options.interaction.summary.slice(0, 200) }\n : {}),\n // The entry point, when the door knows it. Written only when present:\n // an absent field is a door that has none, and Firestore rejects\n // `undefined` inside an array element outright.\n ...(options.interaction.formId\n ? { formId: String(options.interaction.formId).slice(0, 128) }\n : {}),\n ...(options.interaction.path\n ? { path: String(options.interaction.path).slice(0, 500) }\n : {}),\n }\n\n /*\n * THE CAMPAIGNS THIS CAPTURE FILES THE PERSON UNDER.\n *\n * Normalized here rather than trusted, because it reaches this function\n * from a public endpoint's document read and every reader of the stored\n * array goes through the same coercion.\n */\n const campaignIds = normalizeCampaignIds(options.campaignIds ?? [])\n /*\n * THE CUSTOM FIELD VALUES THIS CAPTURE CARRIES, as one nested map.\n *\n * Only the keys the door resolved. Written in the NESTED form because both\n * writes below are merge-sets, which deep-merge a map one key at a time:\n * `custom: { tier: 'Gold' }` lands beside an existing `custom.vip` and\n * leaves it standing. A dotted `facets.h1.custom.tier` path would be a\n * literal field name to a `set`, and a `custom` written whole would take\n * every other key with it.\n */\n const customEntries = Object.entries(options.facet?.custom ?? {})\n const customFacet = customEntries.length\n ? { custom: Object.fromEntries(customEntries) }\n : {}\n\n /*==========================================\n * THE DEDUPE LOOKUP IS UNSCOPED, AND HAS TO BE.\n *\n * One human who touched two sites is ONE person. Narrowing this read to\n * what the capturing site may already see would make a second submission\n * on a sibling brand create a SECOND document for the same address —\n * which loses the dedupe the shared address book exists for, and bills\n * the org twice for one human.\n *\n * Recognizing somebody is not the same act as being allowed to read their\n * row, and the two were the same query while every contact was stamped\n * org-wide. They are separated here: this finds the person, and\n * `visibleTo` below decides who may see them — widened by the capture\n * that just happened, never by the lookup that found them.\n *\n * THROUGH THE ADDRESS INDEX FIRST (AGL-2625). A person whose two records\n * were merged answers to two addresses, and only one of them is the\n * document's `email`; the index is what lets a capture on the other one\n * land on the survivor instead of minting the duplicate again. The query\n * stays as the fallback, so a row the index has not seen costs one extra\n * read once and needs no backfill.\n *=========================================*/\n const docSnapshot = await findContactByEmail(contactsRef, email)\n\n if (docSnapshot) {\n /*\n * MERGED INTO THIS GROUP'S FACET, not into the top of the document.\n *\n * `sources`, `interactions`, the tags, the notes and every commercial\n * figure are the HOLDER's own business records: a booking taken by one\n * client of an agency is that client's, and while these lived at the\n * top of a shared row every other client could read them. The identity\n * — the address, and a canonical name for a holder that has set none of\n * its own — stays shared, because that is what makes this one row.\n */\n const facet = readContactFacet(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const merged = mergeContactInteraction(\n {\n name: facet.name ?? undefined,\n sources: facet.sources,\n interactions: facet.interactions,\n },\n { source: options.source, interaction, name: options.name },\n )\n /*\n * THE PROFILE, as this door knows it (AGL-2596).\n *\n * Given keys only, so the merge below leaves untouched whatever another\n * door wrote. The stage is the one field with a rule of its own: the\n * door's `initialLifecycleStage` fills an empty stage and advances an\n * earlier one, and never moves anybody back —\n * `advanceContactLifecycleStage` is that rule, applied to the stage\n * this door asked for outright or, failing that, the one already\n * stored. Written only when it comes back with something: a door that\n * named no stage and found none leaves the key absent, which reads as\n * \"no stage\" rather than as a stage somebody picked.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage ?? facet.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n /*\n * THE FORM MIRROR, bounded (see `HostContact.formIds`).\n *\n * Read off the document the lookup already fetched, so the bound costs\n * no extra read: a person who has come in through twenty forms keeps\n * the twenty, and this capture's form stays on the interaction alone.\n * `arrayUnion` for the add, so a concurrent capture on a sibling site\n * cannot drop this one's id.\n */\n const heldFormIds: unknown[] = Array.isArray(\n docSnapshot.get(CONTACT_FORM_IDS_FIELD),\n )\n ? docSnapshot.get(CONTACT_FORM_IDS_FIELD)\n : []\n const formIdToAdd =\n interaction.formId &&\n !heldFormIds.includes(interaction.formId) &&\n heldFormIds.length < CONTACT_FORM_IDS_CAP\n ? interaction.formId\n : null\n /*\n * THE COMPANY LINK, when the door carried one (AGL-2613).\n *\n * The facet's `companyId` is written with the rest of the profile\n * below, but the facet is not the whole association: the top-level\n * `companyIds` mirror is what the company page queries, and the\n * company's `contactsCount` is what the companies list shows. The\n * planner reads the row as it stands — this holder's previous link,\n * the mirror, the other holders' ids — and says what the mirror\n * becomes and which counts move; the mirror change rides in this same\n * merge-set, and the counts settle after it.\n */\n const link =\n profile.companyId !== undefined\n ? planContactCompanyLink(\n readContactCompanyLink(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n ),\n profile.companyId,\n )\n : null\n const mirror = link ? contactCompanyMirrorValue(link) : undefined\n await docSnapshot.ref.set(\n {\n // The search keys travel WITH the name, and only when the name is\n // written: stamping an empty key over a real one would make the\n // contact unfindable by the name it still displays.\n ...(merged.name ? nameSearchFields(merged.name) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The search echo of the facet's company name — see `HostContact.companyName`.\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n /*\n * NESTED, not dot-pathed. This is a `set(…, { merge: true })`, and\n * a merge-set treats a key containing dots as a literal field name\n * — only `update()` reads them as paths. The nested form is what\n * Firestore deep-merges, so this writes one holder's facet and\n * leaves every other holder's untouched.\n */\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: merged.sources,\n interactions: merged.interactions,\n ...(merged.name ? { name: merged.name } : {}),\n /*\n * ADDED TO, never replaced. A person who filled in the spring\n * form and later the summer one is in both pushes, and an\n * assignment that overwrote would take a campaign a merchant\n * filed them under back out with nothing on screen to say so —\n * the reason `campaign-membership.ts` made the field an array\n * and the automation step has always used `arrayUnion`.\n *\n * Nested under the group id rather than written at\n * `contactCampaignFieldPath`, because this is a merge-`set`: a\n * `set` treats a dotted string as a literal field NAME and would\n * mint a top-level key with dots in it. Only `update()` reads\n * dots as a path. The nested form is what Firestore deep-merges,\n * so it reaches one holder's facet and leaves every other\n * holder's alone — the same guarantee the dotted path gives the\n * automation step, in the shape this write is allowed to take.\n */\n ...(campaignIds.length\n ? { campaignIds: FieldValue.arrayUnion(...campaignIds) }\n : {}),\n // The same union rule as the campaigns above it, for the same\n // reason: a tag the merchant put on by hand survives the file.\n ...(tags.length ? { tags: FieldValue.arrayUnion(...tags) } : {}),\n // Present keys only, deep-merged by the merge-set — so the\n // fields this door did not carry keep whatever they held.\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: FieldValue.increment(options.purchaseCents),\n ordersCount: FieldValue.increment(1),\n lastPurchaseAtMs: Date.now(),\n // A contact that EXISTED before their first purchase\n // reached this branch, and it never wrote\n // `firstPurchaseAtMs` — only the create path did. So\n // every converted lead permanently lacked RFM's R anchor\n // while walk-in buyers carried it. Set it on the first\n // purchase only; later purchases must not move it.\n ...(facet.firstPurchaseAtMs\n ? {}\n : { firstPurchaseAtMs: Date.now() }),\n }\n : {}),\n },\n },\n /*\n * ATTRIBUTION GROWS ON THE MERGE BRANCH, which the create-only\n * `hostId` beside it never did — so a person the first site\n * captured and the second site later met read as the first site's\n * alone, forever.\n *\n * `arrayUnion`, so the audience filter \"everyone captured on A, B\n * or C\" answers with the sites that actually met this person.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(options.hostId),\n ...(formIdToAdd\n ? { [CONTACT_FORM_IDS_FIELD]: FieldValue.arrayUnion(formIdToAdd) }\n : {}),\n /*\n * AND SO DOES VISIBILITY — by the capture, never by the lookup.\n *\n * This site just collected this person: it has its own relationship\n * with them and may see the row. A site that has never captured\n * them gains nothing here, which is what keeps an agency's clients\n * apart on a document all of them share. The per-interaction\n * `hostId` above is what keeps the HISTORY apart on the same row.\n */\n visibleTo: FieldValue.arrayUnion(...consentGroupScope(group)),\n /*\n * RECORDED AGAINST THE CAPTURING SITE, not against the org.\n *\n * The contact document is shared by every site in the org, which is\n * the point of it — one address book behind many brands. Its\n * consent is not shared on the same terms: the checkbox this\n * capture carried was ticked under one brand's name, on one brand's\n * form, and a basis written at the top of this document made the\n * person mailable by every other brand the account holds.\n *\n * A merge writes one key of the map and leaves the rest, so a\n * person who opts in to a second site accumulates two grants rather\n * than replacing the first.\n */\n ...consentFields,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n await settleCompanyContactsCounts(companiesBeside(contactsRef), link)\n return { contactId: docSnapshot.id, created: false }\n }\n\n /*\n * THE ERASED PERSON DOES NOT COME BACK (AGL-2623).\n *\n * Before the band, on the CREATE branch only — the one read this adds\n * is paid by a capture that would otherwise make a new row, never by\n * the merge above. An erasure wrote a row on this site's suppression\n * list with no address on it; finding that row here is what keeps the\n * next form fill or checkout from rebuilding the record the workspace\n * just erased. The order or booking that triggered the capture still\n * succeeds, exactly as it does when the band refuses: the person bought\n * something, and the merchant's record of the sale is not the CRM's\n * record of the person.\n */\n if (await hostRefusesCaptureForErasure(options.hostId, email, firestore)) {\n return { refused: 'erased' }\n }\n\n // New contact: records-band check via the aggregate counts (cheap; no\n // doc reads) against the owning org's entitlements (AGL-238/890).\n // Metered plans always pass (overage bills via the report-usage\n // cron); only free's hard band drops the CRM record — visibly, via\n // the counter and the console alert (AGL-891). The signup/order that\n // triggered the capture always succeeds either way.\n //\n // THE RECORDS BAND, not the contacts headcount (AGL-2611): companies\n // and deals share this band, so a capture is refused on a Free org\n // whose hundred records are ninety companies and ten people — the same\n // verdict the company drawer would give the ninety-first company.\n // `contactsRef` is handed in so the contacts aggregate is the one this\n // door already reads; the org reference is its parent.\n const orgBilling = await getOrgForHost(options.hostId)\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const { crmRecordsCount } = await countCrmRecords(orgRef, contactsRef)\n const quota = checkCrmRecordsQuota(\n (orgBilling?.org as any) ?? null,\n crmRecordsCount,\n )\n if (!quota.allowed) {\n await hostRef\n .collection('counters')\n .doc('contactsDropped')\n .set({ total: FieldValue.increment(1) }, { merge: true })\n return { refused: 'band' }\n }\n\n /*\n * The profile on a create, with the door's stage floor applied the same\n * way as on a merge — there is no stored stage yet, so the floor is what\n * a door that named one writes. A `null` address is dropped rather than\n * written: there is nothing on a new document for it to clear.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n if (profile.address === null) delete profile.address\n\n const created = await contactsRef.add({\n hostId: options.hostId,\n /*\n * WHICH SITES HAVE MET THIS PERSON — attribution, and separate from\n * consent above.\n *\n * The scalar `hostId` beside it names the FIRST capturing site and is\n * never rewritten, which is provenance for the ROW. This array is the\n * one that grows and the one an audience query can filter on.\n */\n [CAPTURED_BY_HOST_FIELD]: [options.hostId],\n // The form mirror's first entry, when this capture came through one —\n // see `HostContact.formIds`. Absent otherwise, like the campaigns.\n ...(interaction.formId\n ? { [CONTACT_FORM_IDS_FIELD]: [interaction.formId] }\n : {}),\n /*\n * THE CAPTURING GROUP, not the whole org.\n *\n * Stamping `['org']` here made every contact readable by every site in\n * the account on the day it was created — so an agency's twelve clients\n * shared one address book by default, and closing the missing-field\n * fail-open would not have touched it, because the field was present\n * and said so.\n *\n * A group of one — the default — is this site alone. A declared group\n * is the sites that already present as one sender. Widening beyond that\n * is available and is an ACT: an org may set `defaultResourceScope` to\n * `'org'`, or a later capture on a sibling site unions that site in.\n */\n visibleTo:\n (orgBilling?.org as { defaultResourceScope?: 'org' | 'host' } | null)\n ?.defaultResourceScope === 'org'\n ? [ORG_SCOPE_TOKEN]\n : consentGroupScope(group),\n email,\n ...(options.name ? nameSearchFields(options.name.slice(0, 120)) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The search echo of the facet's company name — see `HostContact.companyName`.\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n // The company mirror the company page queries (AGL-2613) — a create\n // has nothing to union with, so the door's company is the whole list.\n ...(profile.companyId\n ? { [CONTACT_COMPANY_IDS_FIELD]: [profile.companyId] }\n : {}),\n // The facet this capture creates. Everything a holder owns lives under\n // its own group id; the address and the canonical name above are the\n // only shared identity.\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: { [options.source]: true },\n interactions: [interaction],\n tags,\n // A create has nothing to union with, so the normalized list is the\n // whole membership.\n ...(campaignIds.length ? { campaignIds } : {}),\n ...customFacet,\n ...(options.name\n ? { name: options.name.slice(0, 120) }\n : {}),\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: options.purchaseCents,\n ordersCount: 1,\n lastPurchaseAtMs: Date.now(),\n firstPurchaseAtMs: Date.now(),\n }\n : {}),\n },\n },\n ...consentFields,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The address now resolves to this row (AGL-2625). Its own catch inside\n // the writer, so an index the capture could not reach costs nothing.\n await writeContactEmailIndex(emailIndexBeside(contactsRef), created.id, [email])\n // The company the door named now has one more contact naming it\n // (AGL-2613) — the fresh row's plan is the trivial one, nothing held\n // before and nothing held elsewhere.\n if (profile.companyId) {\n await settleCompanyContactsCounts(\n companiesBeside(contactsRef),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n profile.companyId,\n ),\n )\n }\n /*\n * ATTRIBUTED ON CREATION ONLY, and therefore below the band gate rather\n * than above it — the opposite placement to the order join at the top of\n * this function, deliberately.\n *\n * The order join sits above because it credits MONEY, which is real\n * whether or not a CRM record was kept for the buyer, and a Free org past\n * its included band would otherwise silently drop the revenue with the\n * contact. This credits a contact, which does not exist when the band\n * dropped it: attributing one above the gate would report a campaign as\n * having produced people the customer cannot see anywhere in the console.\n *\n * The existing-contact branch above returns without reaching here, which\n * is the same rule `addHostLead` applies — an interaction appended to\n * somebody the site already held is another visit, not a new person, and\n * crediting it would let the most recent campaign re-earn the whole list.\n */\n if (options.campaignTouch) {\n await attributeCampaignConversion({\n hostId: options.hostId,\n kind: 'contact',\n refId: created.id,\n touch: options.campaignTouch,\n convertedAtMs: interaction.atMs,\n })\n }\n /*\n * REPORTED ON CREATION ONLY, below the band gate for the reason the\n * attribution above is: a contact the band dropped does not exist, and\n * an automation told about it would act on a person the console cannot\n * show. The merge branch returned long before this line, so a repeat\n * visit by somebody already held is never announced as a new contact.\n */\n if (options.onCreated) {\n await Promise.resolve(\n options.onCreated({\n contactId: created.id,\n hostId: options.hostId,\n email,\n ...(options.name ? { name: options.name.slice(0, 120) } : {}),\n source: options.source,\n campaignIds,\n ...(profile.lifecycleStage\n ? { lifecycleStage: profile.lifecycleStage }\n : {}),\n }),\n ).catch((error) => {\n console.error('upsertHostContact onCreated failed', error)\n })\n }\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('upsertHostContact failed', error)\n return { refused: 'error' }\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkCrmRecordsQuota","consentGroupScope","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","marketingConsentFieldsForGroup","mergeContactInteraction","normalizeCampaignIds","readContactFacet","normalizeContactEmail","ORG_SCOPE_TOKEN","FieldValue","firebaseAdmin","countCrmRecords","attributeOrderToEmail","hostRefusesCaptureForErasure","attributeCampaignConversion","nameSearchFields","consentGroupForGrant","declineMarketingConsentFields","MARKETING_CONSENT_SOURCE_FIELD","advanceContactLifecycleStage","CONTACT_COMPANY_IDS_FIELD","CONTACT_FIELD_KEY_PATTERN","CRM_COLLECTIONS","isContactLifecycleStage","planContactCompanyLink","readContactCompanyLink","normalizeAddress","normalizePhone","contactCompanyMirrorValue","settleCompanyContactsCounts","emailIndexBeside","findContactByEmail","writeContactEmailIndex","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","storableProfile","input","out","phone","undefined","jobTitle","trim","slice","leadSource","replace","address","companyId","companyName","ownerUid","lifecycleStage","custom","key","value","Object","entries","test","keys","length","companiesBeside","contactsRef","parent","collection","companies","normalizeTags","tags","Set","map","tag","String","toLowerCase","filter","Boolean","upsertHostContact","options","orgBilling","email","refused","source","interaction","refId","hostId","orderId","amountCents","Number","purchaseCents","purchaseCurrency","currency","orderedAtMs","atMs","Date","now","catch","firestore","app","hostRef","doc","group","consentExtra","marketingConsentSource","consentFields","marketingConsent","disclosedConsentGroup","declineMarketingConsent","type","summary","formId","path","campaignIds","customEntries","facet","customFacet","fromEntries","docSnapshot","profile","data","groupId","merged","name","sources","interactions","advanced","initialLifecycleStage","heldFormIds","Array","isArray","get","formIdToAdd","includes","link","mirror","ref","set","arrayUnion","ltvCents","increment","ordersCount","lastPurchaseAtMs","firstPurchaseAtMs","visibleTo","updatedAt","serverTimestamp","merge","contactId","id","created","orgRef","orgId","crmRecordsCount","quota","org","allowed","total","add","defaultResourceScope","createdAt","companyIds","heldElsewhere","campaignTouch","kind","touch","convertedAtMs","onCreated","Promise","resolve","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,sBAAsB,EACtBC,oBAAoB,EACpBC,iBAAiB,EACjBC,oBAAoB,EACpBC,oBAAoB,EACpBC,sBAAsB,EAKtBC,8BAA8B,EAC9BC,uBAAuB,EACvBC,oBAAoB,EACpBC,gBAAgB,EAChBC,qBAAqB,EACrBC,eAAe,QACV,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,aAAa,QAAQ,sBAAkB;AAChD,SAASC,eAAe,QAAQ,mBAAe;AAC/C,SAASC,qBAAqB,QAAQ,iCAA6B;AACnE,SAASC,4BAA4B,QAAQ,yBAAqB;AAClE,SACEC,2BAA2B,QAEtB,uCAAmC;AAC1C,SAASC,gBAAgB,QAAQ,qCAAoC;AACrE,SAASC,oBAAoB,QAAQ,wCAAuC;AAC5E,SACEC,6BAA6B,EAC7BC,8BAA8B,QAEzB,2CAA0C;AACjD;;;;;;CAMC,GACD,SACEC,4BAA4B,EAC5BC,yBAAyB,EACzBC,yBAAyB,EAEzBC,eAAe,EACfC,uBAAuB,EACvBC,sBAAsB,EACtBC,sBAAsB,QACjB,6BAA4B;AACnC,SACEC,gBAAgB,EAChBC,cAAc,QACT,oDAAmD;AAC1D,SACEC,yBAAyB,EACzBC,2BAA2B,QACtB,4BAAwB;AAC/B,SACEC,gBAAgB,EAChBC,kBAAkB,EAClBC,sBAAsB,QACjB,2BAAuB;AAC9B,SACEC,mBAAmB,EACnBC,aAAa,EACbC,wBAAwB,QACnB,qBAAiB;AAiExB;;;;;;;;;CASC,GACD,SAASC,gBAAgBC,KAAsC;IAW7D,IAAI,CAACA,OAAO,OAAO,CAAC;IACpB,MAAMC,MAA0C,CAAC;IACjD,IAAID,MAAME,KAAK,KAAKC,WAAW;QAC7B,MAAMD,QAAQZ,eAAeU,MAAME,KAAK;QACxC,IAAIA,OAAOD,IAAIC,KAAK,GAAGA;IACzB;IACA,IAAI,OAAOF,MAAMI,QAAQ,KAAK,UAAU;QACtC,MAAMA,WAAWJ,MAAMI,QAAQ,CAACC,IAAI,GAAGC,KAAK,CAAC,GAAG;QAChD,IAAIF,UAAUH,IAAIG,QAAQ,GAAGA;IAC/B;IACA,yEAAyE;IACzE,uEAAuE;IACvE,IAAI,OAAOJ,MAAMO,UAAU,KAAK,UAAU;QACxC,MAAMA,aAAaP,MAAMO,UAAU,CAACF,IAAI,GAAGG,OAAO,CAAC,QAAQ,KAAKF,KAAK,CAAC,GAAG;QACzE,IAAIC,YAAYN,IAAIM,UAAU,GAAGA;IACnC;IACA,IAAIP,MAAMS,OAAO,KAAKN,WAAWF,IAAIQ,OAAO,GAAGpB,iBAAiBW,MAAMS,OAAO;IAC7E,IAAI,OAAOT,MAAMU,SAAS,KAAK,YAAYV,MAAMU,SAAS,CAACL,IAAI,IAAI;QACjEJ,IAAIS,SAAS,GAAGV,MAAMU,SAAS,CAACL,IAAI,GAAGC,KAAK,CAAC,GAAG;IAClD;IACA,uEAAuE;IACvE,wEAAwE;IACxE,IAAI,OAAON,MAAMW,WAAW,KAAK,YAAYX,MAAMW,WAAW,CAACN,IAAI,IAAI;QACrEJ,IAAIU,WAAW,GAAGX,MAAMW,WAAW,CAACN,IAAI,GAAGC,KAAK,CAAC,GAAG;IACtD;IACA,IAAI,OAAON,MAAMY,QAAQ,KAAK,YAAYZ,MAAMY,QAAQ,CAACP,IAAI,IAAI;QAC/DJ,IAAIW,QAAQ,GAAGZ,MAAMY,QAAQ,CAACP,IAAI,GAAGC,KAAK,CAAC,GAAG;IAChD;IACA,IAAIpB,wBAAwBc,MAAMa,cAAc,GAAG;QACjDZ,IAAIY,cAAc,GAAGb,MAAMa,cAAc;IAC3C;IACA,IAAIb,MAAMc,MAAM,IAAI,OAAOd,MAAMc,MAAM,KAAK,UAAU;QACpD;;;;;;;KAOC,GACD,MAAMA,SAA6C,CAAC;QACpD,KAAK,MAAM,CAACC,KAAKC,MAAM,IAAIC,OAAOC,OAAO,CAAClB,MAAMc,MAAM,EAAG;YACvD,IAAI,CAAC9B,0BAA0BmC,IAAI,CAACJ,MAAM;YAC1C,IACEC,UAAU,QACV,OAAOA,UAAU,YACjB,OAAOA,UAAU,YACjB,OAAOA,UAAU,WACjB;gBACAF,MAAM,CAACC,IAAI,GAAG,OAAOC,UAAU,WAAWA,MAAMV,KAAK,CAAC,GAAG,QAAQU;YACnE;QACF;QACA,IAAIC,OAAOG,IAAI,CAACN,QAAQO,MAAM,EAAEpB,IAAIa,MAAM,GAAGA;IAC/C;IACA,OAAOb;AACT;AAEA;;;;;;;;CAQC,GACD,SAASqB,gBACPC,WAAkD;IAElD,MAAMC,SAASD,YAAYC,MAAM;IACjC,OAAOA,SAASA,OAAOC,UAAU,CAACxC,gBAAgByC,SAAS,IAAI;AACjE;AAEA;;;CAGC,GACD,SAASC,cAAcC,IAAmC;IACxD,OAAO;WACF,IAAIC,IACL,CAACD,eAAAA,OAAQ,EAAE,EACRE,GAAG,CAAC,CAACC,MAAQC,OAAOD,cAAAA,MAAO,IAAI1B,IAAI,GAAG4B,WAAW,IACjDC,MAAM,CAACC;KAEb,CAAC7B,KAAK,CAAC,GAAG;AACb;AAqNA,OAAO,eAAe8B,kBACpBC,OAAiC;IAEjC,IAAI;YAoFMA,2BA6BiCA,4BA8QvCd;YAnQmCc,gBA+ThCC;QA1bL,MAAMC,QAAQrE,sBAAsBmE,QAAQE,KAAK;QACjD,IAAI,CAACA,OAAO,OAAO;YAAEC,SAAS;QAAgB;QAC9C,MAAMZ,OAAOD,cAAcU,QAAQT,IAAI;QAEvC;;;;;;;;;;;;;;;;;;;;;+CAqB2C,GAC3C,IAAIS,QAAQI,MAAM,KAAK,WAAWJ,QAAQK,WAAW,CAACC,KAAK,EAAE;gBAKrCN,wBAIPA;YARf,MAAM9D,sBAAsB;gBAC1BqE,QAAQP,QAAQO,MAAM;gBACtBC,SAASb,OAAOK,QAAQK,WAAW,CAACC,KAAK;gBACzCJ;gBACAO,aAAaC,QAAOV,yBAAAA,QAAQW,aAAa,YAArBX,yBAAyB;eACzCA,QAAQY,gBAAgB,GACxB;gBAAEC,UAAUb,QAAQY,gBAAgB;YAAC,IACrC,CAAC;gBACLE,WAAW,GAAEd,6BAAAA,QAAQK,WAAW,CAACU,IAAI,YAAxBf,6BAA4BgB,KAAKC,GAAG;gBAChDC,KAAK,CAAC,IAAM;QACjB;QAEA,MAAMC,YAAYnF,cAAcoF,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UAAU/B,UAAU,CAAC,SAASkC,GAAG,CAACtB,QAAQO,MAAM;QAChE,qEAAqE;QACrE,eAAe;QACf,MAAMrB,cAAc,MAAMzB,yBACxBuC,QAAQO,MAAM,EACd;QAEF;;;;;;KAMC,GACD,MAAMgB,QAAQ,MAAMhE,oBAAoByC,QAAQO,MAAM;QACtD;;;;;;;;;;;;;KAaC,GACD,MAAMiB,eAAexB,QAAQyB,sBAAsB,GAC/C;YAAE,CAACjF,+BAA+B,EAAEwD,QAAQyB,sBAAsB;QAAC,IACnE3D;QACJ,MAAM4D,gBAAgB1B,QAAQ2B,gBAAgB,GAC1ClG,+BACEa,qBAAqBiF,OAAOvB,QAAQ4B,qBAAqB,GACzDZ,KAAKC,GAAG,IACRO,gBAEFxB,QAAQ6B,uBAAuB,GAC7BtF,8BAA8ByD,QAAQO,MAAM,EAAES,KAAKC,GAAG,IAAIO,gBAC1D,CAAC;QACP,MAAMnB,cAAkC;YACtCyB,MAAM9B,QAAQI,MAAM;YACpBW,IAAI,GAAEf,4BAAAA,QAAQK,WAAW,CAACU,IAAI,YAAxBf,4BAA4BgB,KAAKC,GAAG;YAC1C,uEAAuE;YACvE,gEAAgE;YAChE,kEAAkE;YAClEV,QAAQP,QAAQO,MAAM;WAClBP,QAAQK,WAAW,CAACC,KAAK,GACzB;YAAEA,OAAON,QAAQK,WAAW,CAACC,KAAK;QAAC,IACnC,CAAC,GACDN,QAAQK,WAAW,CAAC0B,OAAO,GAC3B;YAAEA,SAAS/B,QAAQK,WAAW,CAAC0B,OAAO,CAAC9D,KAAK,CAAC,GAAG;QAAK,IACrD,CAAC,GAID+B,QAAQK,WAAW,CAAC2B,MAAM,GAC1B;YAAEA,QAAQrC,OAAOK,QAAQK,WAAW,CAAC2B,MAAM,EAAE/D,KAAK,CAAC,GAAG;QAAK,IAC3D,CAAC,GACD+B,QAAQK,WAAW,CAAC4B,IAAI,GACxB;YAAEA,MAAMtC,OAAOK,QAAQK,WAAW,CAAC4B,IAAI,EAAEhE,KAAK,CAAC,GAAG;QAAK,IACvD,CAAC;QAGP;;;;;;KAMC,GACD,MAAMiE,cAAcvG,sBAAqBqE,uBAAAA,QAAQkC,WAAW,YAAnBlC,uBAAuB,EAAE;QAClE;;;;;;;;;KASC,GACD,MAAMmC,gBAAgBvD,OAAOC,OAAO,UAACmB,iBAAAA,QAAQoC,KAAK,qBAAbpC,eAAevB,MAAM,mBAAI,CAAC;QAC/D,MAAM4D,cAAcF,cAAcnD,MAAM,GACpC;YAAEP,QAAQG,OAAO0D,WAAW,CAACH;QAAe,IAC5C,CAAC;QAEL;;;;;;;;;;;;;;;;;;;;;+CAqB2C,GAC3C,MAAMI,cAAc,MAAMlF,mBAAmB6B,aAAagB;QAE1D,IAAIqC,aAAa;gBAiBLH,aAqBRI;YArCF;;;;;;;;;OASC,GACD,MAAMJ,QAAQxG,iBACZ2G,YAAYE,IAAI,IAChBlB,MAAMmB,OAAO;YAEf,MAAMC,SAASjH,wBACb;gBACEkH,IAAI,GAAER,cAAAA,MAAMQ,IAAI,YAAVR,cAActE;gBACpB+E,SAAST,MAAMS,OAAO;gBACtBC,cAAcV,MAAMU,YAAY;YAClC,GACA;gBAAE1C,QAAQJ,QAAQI,MAAM;gBAAEC;gBAAauC,MAAM5C,QAAQ4C,IAAI;YAAC;YAE5D;;;;;;;;;;;;OAYC,GACD,MAAMJ,UAAU9E,gBAAgBsC,QAAQoC,KAAK;YAC7C,MAAMW,WAAWtG,8BACf+F,0BAAAA,QAAQhE,cAAc,YAAtBgE,0BAA0BJ,MAAM5D,cAAc,EAC9CwB,QAAQgD,qBAAqB;YAE/B,IAAID,UAAUP,QAAQhE,cAAc,GAAGuE;YACvC;;;;;;;;OAQC,GACD,MAAME,cAAyBC,MAAMC,OAAO,CAC1CZ,YAAYa,GAAG,CAAC5H,2BAEd+G,YAAYa,GAAG,CAAC5H,0BAChB,EAAE;YACN,MAAM6H,cACJhD,YAAY2B,MAAM,IAClB,CAACiB,YAAYK,QAAQ,CAACjD,YAAY2B,MAAM,KACxCiB,YAAYjE,MAAM,GAAGzD,uBACjB8E,YAAY2B,MAAM,GAClB;YACN;;;;;;;;;;;OAWC,GACD,MAAMuB,OACJf,QAAQnE,SAAS,KAAKP,YAClBhB,uBACEC,uBACEwF,YAAYE,IAAI,IAChBlB,MAAMmB,OAAO,GAEfF,QAAQnE,SAAS,IAEnB;YACN,MAAMmF,SAASD,OAAOrG,0BAA0BqG,QAAQzF;YACxD,MAAMyE,YAAYkB,GAAG,CAACC,GAAG,CACvB,aAIMf,OAAOC,IAAI,GAAGvG,iBAAiBsG,OAAOC,IAAI,IAAI,CAAC,GAE/CJ,QAAQ3E,KAAK,GAAG;gBAAEA,OAAO2E,QAAQ3E,KAAK;YAAC,IAAI,CAAC,GAE5C2E,QAAQlE,WAAW,GAAG;gBAAEA,aAAakE,QAAQlE,WAAW;YAAC,IAAI,CAAC,GAC9DkF,WAAW1F,YAAY;gBAAE,CAACpB,0BAA0B,EAAE8G;YAAO,IAAI,CAAC;gBACtE;;;;;;WAMC,GACD,CAAClI,qBAAqB,EAAE;oBACtB,CAACiG,MAAMmB,OAAO,CAAC,EAAE;wBACfG,SAASF,OAAOE,OAAO;wBACvBC,cAAcH,OAAOG,YAAY;uBAC7BH,OAAOC,IAAI,GAAG;wBAAEA,MAAMD,OAAOC,IAAI;oBAAC,IAAI,CAAC,GAkBvCV,YAAYlD,MAAM,GAClB;wBAAEkD,aAAanG,WAAW4H,UAAU,IAAIzB;oBAAa,IACrD,CAAC,GAGD3C,KAAKP,MAAM,GAAG;wBAAEO,MAAMxD,WAAW4H,UAAU,IAAIpE;oBAAM,IAAI,CAAC,GAG3DiD,SACCxC,QAAQW,aAAa,GACrB;wBACEiD,UAAU7H,WAAW8H,SAAS,CAAC7D,QAAQW,aAAa;wBACpDmD,aAAa/H,WAAW8H,SAAS,CAAC;wBAClCE,kBAAkB/C,KAAKC,GAAG;uBAOtBmB,MAAM4B,iBAAiB,GACvB,CAAC,IACD;wBAAEA,mBAAmBhD,KAAKC,GAAG;oBAAG,KAEtC,CAAC;gBAET;gBACA;;;;;;;;WAQC,GACD,CAAC9F,uBAAuB,EAAEY,WAAW4H,UAAU,CAAC3D,QAAQO,MAAM;eAC1D8C,cACA;gBAAE,CAAC7H,uBAAuB,EAAEO,WAAW4H,UAAU,CAACN;YAAa,IAC/D,CAAC;gBACL;;;;;;;;WAQC,GACDY,WAAWlI,WAAW4H,UAAU,IAAItI,kBAAkBkG;eAenDG;gBACHwC,WAAWnI,WAAWoI,eAAe;gBAEvC;gBAAEC,OAAO;YAAK;YAEhB,MAAMjH,4BAA4B8B,gBAAgBC,cAAcqE;YAChE,OAAO;gBAAEc,WAAW9B,YAAY+B,EAAE;gBAAEC,SAAS;YAAM;QACrD;QAEA;;;;;;;;;;;;KAYC,GACD,IAAI,MAAMpI,6BAA6B6D,QAAQO,MAAM,EAAEL,OAAOiB,YAAY;YACxE,OAAO;gBAAEhB,SAAS;YAAS;QAC7B;QAEA,sEAAsE;QACtE,kEAAkE;QAClE,gEAAgE;QAChE,mEAAmE;QACnE,qEAAqE;QACrE,oDAAoD;QACpD,EAAE;QACF,qEAAqE;QACrE,mEAAmE;QACnE,uEAAuE;QACvE,kEAAkE;QAClE,uEAAuE;QACvE,uDAAuD;QACvD,MAAMF,aAAa,MAAMzC,cAAcwC,QAAQO,MAAM;QACrD,MAAMiE,UACJtF,sBAAAA,YAAYC,MAAM,YAAlBD,sBACAiC,UAAU/B,UAAU,CAAC,QAAQkC,GAAG,CAAC3B,gBAAOM,8BAAAA,WAAYwE,KAAK,oBAAI;QAC/D,MAAM,EAAEC,eAAe,EAAE,GAAG,MAAMzI,gBAAgBuI,QAAQtF;QAC1D,MAAMyF,QAAQvJ,8BACX6E,8BAAAA,WAAY2E,GAAG,oBAAY,MAC5BF;QAEF,IAAI,CAACC,MAAME,OAAO,EAAE;YAClB,MAAMxD,QACHjC,UAAU,CAAC,YACXkC,GAAG,CAAC,mBACJoC,GAAG,CAAC;gBAAEoB,OAAO/I,WAAW8H,SAAS,CAAC;YAAG,GAAG;gBAAEO,OAAO;YAAK;YACzD,OAAO;gBAAEjE,SAAS;YAAO;QAC3B;QAEA;;;;;KAKC,GACD,MAAMqC,UAAU9E,gBAAgBsC,QAAQoC,KAAK;QAC7C,MAAMW,WAAWtG,6BACf+F,QAAQhE,cAAc,EACtBwB,QAAQgD,qBAAqB;QAE/B,IAAID,UAAUP,QAAQhE,cAAc,GAAGuE;QACvC,IAAIP,QAAQpE,OAAO,KAAK,MAAM,OAAOoE,QAAQpE,OAAO;QAEpD,MAAMmG,UAAU,MAAMrF,YAAY6F,GAAG,CAAC;YACpCxE,QAAQP,QAAQO,MAAM;YACtB;;;;;;;OAOC,GACD,CAACpF,uBAAuB,EAAE;gBAAC6E,QAAQO,MAAM;aAAC;WAGtCF,YAAY2B,MAAM,GAClB;YAAE,CAACxG,uBAAuB,EAAE;gBAAC6E,YAAY2B,MAAM;aAAC;QAAC,IACjD,CAAC;YACL;;;;;;;;;;;;;OAaC,GACDiC,WACE,CAAChE,+BAAAA,kBAAAA,WAAY2E,GAAG,qBAAhB,AAAC3E,gBACG+E,oBAAoB,MAAK,QACzB;gBAAClJ;aAAgB,GACjBT,kBAAkBkG;YACxBrB;WACIF,QAAQ4C,IAAI,GAAGvG,iBAAiB2D,QAAQ4C,IAAI,CAAC3E,KAAK,CAAC,GAAG,QAAQ,CAAC,GAE/DuE,QAAQ3E,KAAK,GAAG;YAAEA,OAAO2E,QAAQ3E,KAAK;QAAC,IAAI,CAAC,GAE5C2E,QAAQlE,WAAW,GAAG;YAAEA,aAAakE,QAAQlE,WAAW;QAAC,IAAI,CAAC,GAG9DkE,QAAQnE,SAAS,GACjB;YAAE,CAAC3B,0BAA0B,EAAE;gBAAC8F,QAAQnE,SAAS;aAAC;QAAC,IACnD,CAAC;YACL,uEAAuE;YACvE,qEAAqE;YACrE,wBAAwB;YACxB,CAAC/C,qBAAqB,EAAE;gBACtB,CAACiG,MAAMmB,OAAO,CAAC,EAAE;oBACfG,SAAS;wBAAE,CAAC7C,QAAQI,MAAM,CAAC,EAAE;oBAAK;oBAClC0C,cAAc;wBAACzC;qBAAY;oBAC3Bd;mBAGI2C,YAAYlD,MAAM,GAAG;oBAAEkD;gBAAY,IAAI,CAAC,GACzCG,aACCrC,QAAQ4C,IAAI,GACZ;oBAAEA,MAAM5C,QAAQ4C,IAAI,CAAC3E,KAAK,CAAC,GAAG;gBAAK,IACnC,CAAC,GACFuE,SACCxC,QAAQW,aAAa,GACrB;oBACEiD,UAAU5D,QAAQW,aAAa;oBAC/BmD,aAAa;oBACbC,kBAAkB/C,KAAKC,GAAG;oBAC1B+C,mBAAmBhD,KAAKC,GAAG;gBAC7B,IACA,CAAC;YAET;WACGS;YACHuD,WAAWlJ,WAAWoI,eAAe;YACrCD,WAAWnI,WAAWoI,eAAe;;QAEvC,wEAAwE;QACxE,qEAAqE;QACrE,MAAM7G,uBAAuBF,iBAAiB8B,cAAcqF,QAAQD,EAAE,EAAE;YAACpE;SAAM;QAC/E,gEAAgE;QAChE,qEAAqE;QACrE,qCAAqC;QACrC,IAAIsC,QAAQnE,SAAS,EAAE;YACrB,MAAMlB,4BACJ8B,gBAAgBC,cAChBpC,uBACE;gBAAEuB,WAAW;gBAAM6G,YAAY,EAAE;gBAAEC,eAAe,EAAE;YAAC,GACrD3C,QAAQnE,SAAS;QAGvB;QACA;;;;;;;;;;;;;;;;KAgBC,GACD,IAAI2B,QAAQoF,aAAa,EAAE;YACzB,MAAMhJ,4BAA4B;gBAChCmE,QAAQP,QAAQO,MAAM;gBACtB8E,MAAM;gBACN/E,OAAOiE,QAAQD,EAAE;gBACjBgB,OAAOtF,QAAQoF,aAAa;gBAC5BG,eAAelF,YAAYU,IAAI;YACjC;QACF;QACA;;;;;;KAMC,GACD,IAAIf,QAAQwF,SAAS,EAAE;YACrB,MAAMC,QAAQC,OAAO,CACnB1F,QAAQwF,SAAS,CAAC;gBAChBnB,WAAWE,QAAQD,EAAE;gBACrB/D,QAAQP,QAAQO,MAAM;gBACtBL;eACIF,QAAQ4C,IAAI,GAAG;gBAAEA,MAAM5C,QAAQ4C,IAAI,CAAC3E,KAAK,CAAC,GAAG;YAAK,IAAI,CAAC;gBAC3DmC,QAAQJ,QAAQI,MAAM;gBACtB8B;eACIM,QAAQhE,cAAc,GACtB;gBAAEA,gBAAgBgE,QAAQhE,cAAc;YAAC,IACzC,CAAC,KAEP0C,KAAK,CAAC,CAACyE;gBACPC,QAAQD,KAAK,CAAC,sCAAsCA;YACtD;QACF;QACA,OAAO;YAAEtB,WAAWE,QAAQD,EAAE;YAAEC,SAAS;QAAK;IAChD,EAAE,OAAOoB,OAAO;QACdC,QAAQD,KAAK,CAAC,4BAA4BA;QAC1C,OAAO;YAAExF,SAAS;QAAQ;IAC5B;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/upsert-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynPostalAddress,\n CAPTURED_BY_HOST_FIELD,\n checkCrmRecordsQuota,\n consentGroupScope,\n CONTACT_FACETS_FIELD,\n CONTACT_FORM_IDS_CAP,\n CONTACT_FORM_IDS_FIELD,\n type ContactFacet,\n type ContactInteraction,\n type ContactLifecycleStage,\n type ContactSource,\n marketingConsentFieldsForGroup,\n mergeContactInteraction,\n normalizeCampaignIds,\n readContactFacet,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { firebaseAdmin } from './firebase-admin'\nimport { countCrmRecords, restampCrmListFieldsAt } from './crm-records'\nimport { attributeOrderToEmail } from './email-revenue-attribution'\nimport { hostRefusesCaptureForErasure } from './email-suppression'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n declineMarketingConsentFields,\n MARKETING_CONSENT_SOURCE_FIELD,\n type MarketingConsentSource,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\n/*\n * The module paths, like `name-search` above, rather than the barrel: the\n * pure helpers this door leans on are exactly the ones a spec of the door\n * substitutes a fixture barrel for, and a fixture that has to re-export the\n * whole of `@aglyn/aglyn` to keep a normalizer reachable is a fixture that\n * drifts. A direct path is real in every harness.\n */\nimport {\n advanceContactLifecycleStage,\n CONTACT_COMPANY_IDS_FIELD,\n CONTACT_FIELD_KEY_PATTERN,\n type ContactCustomValue,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n isContactLifecycleStage,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n normalizeAddress,\n normalizePhone,\n} from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n contactCompanyMirrorValue,\n settleCompanyContactsCounts,\n} from './contact-company-link'\nimport {\n emailIndexBeside,\n findContactByEmail,\n writeContactEmailIndex,\n} from './contact-email-index'\nimport {\n consentGroupForSite,\n getOrgForHost,\n orgDataCollectionForHost,\n} from './organizations'\n\n/**\n * What an upsert did, for the callers that need to know (AGL-2602).\n *\n * The capture doors never look: a form submission or an order must succeed\n * whatever happened to the CRM record, which is why this function swallows\n * its own errors and why every existing caller `await`s it for its side\n * effect alone. An IMPORT is the caller that has to know, row by row —\n * \"created\" and \"merged\" are its two headline numbers, and a row the\n * audience band refused has to be handed back to the operator as a row\n * rather than becoming one more tick on a counter nobody reconciles against\n * a file. A verdict is returned rather than thrown so the swallowing stays:\n * `refused: 'error'` is the same silence the doors have always had, now with\n * a name.\n */\nexport type UpsertHostContactVerdict =\n | {\n contactId: string\n /** True when this call created the row; false when it merged into one. */\n created: boolean\n }\n | {\n /**\n * `erased`: the site holds an erasure row for the address (AGL-2623),\n * so no record is created — a capture must not quietly rebuild a\n * person the workspace erased. Only a CREATE is refused; a merge\n * cannot arise, because the erasure removed the row it would merge\n * into.\n */\n refused: 'invalid-email' | 'band' | 'erased' | 'error'\n }\n\n/**\n * The per-holder profile fields a door may write alongside the identity.\n *\n * `Pick`ed from the facet rather than typed afresh so the two cannot drift:\n * a field the facet grows is a field this option may carry the moment the\n * pick names it, and one it does not name is refused at compile time.\n */\nexport type UpsertHostContactFacet = Partial<\n Pick<\n ContactFacet,\n | 'phone'\n | 'jobTitle'\n | 'leadSource'\n | 'companyId'\n | 'companyName'\n | 'address'\n | 'ownerUid'\n | 'lifecycleStage'\n | 'custom'\n >\n>\n\n/**\n * The profile fields a door may hand this function (AGL-2596): the parts of\n * a person's record that no capture surface collects — the console's create\n * drawer and the import do, and the order door adds the stage. `custom` is\n * the holder's own field values, keyed by `ContactFieldDefinition.key`; the\n * import maps spreadsheet columns onto them, and the definitions live under\n * the same group the values are written to.\n */\nexport type ContactProfileInput = UpsertHostContactFacet\n\n/**\n * The profile as it may be STORED: every value normalized, every unusable\n * one dropped, and nothing present that was not given.\n *\n * Only the keys given come back, which is what lets a merge write this\n * straight into the facet: a door that knows the phone number and nothing\n * else leaves the title, the owner and the stage exactly as another door\n * left them. An address given as `null` is a deliberate clearing and is kept\n * as `null`; one that normalizes to nothing is the same thing.\n */\nfunction storableProfile(input: ContactProfileInput | undefined): {\n phone?: string\n jobTitle?: string\n leadSource?: string\n address?: ReturnType<typeof normalizeAddress>\n companyId?: string\n companyName?: string\n ownerUid?: string\n lifecycleStage?: ContactFacet['lifecycleStage']\n custom?: Record<string, ContactCustomValue>\n} {\n if (!input) return {}\n const out: ReturnType<typeof storableProfile> = {}\n if (input.phone !== undefined) {\n const phone = normalizePhone(input.phone)\n if (phone) out.phone = phone\n }\n if (typeof input.jobTitle === 'string') {\n const jobTitle = input.jobTitle.trim().slice(0, 120)\n if (jobTitle) out.jobTitle = jobTitle\n }\n // The lead's value, carried on conversion (AGL-3298). Judged against the\n // org's list by the door that took it; held here to the label's shape.\n if (typeof input.leadSource === 'string') {\n const leadSource = input.leadSource.trim().replace(/\\s+/g, ' ').slice(0, 120)\n if (leadSource) out.leadSource = leadSource\n }\n if (input.address !== undefined) out.address = normalizeAddress(input.address)\n if (typeof input.companyId === 'string' && input.companyId.trim()) {\n out.companyId = input.companyId.trim().slice(0, 128)\n }\n // The name as this holder knows it, beside the link — the merge fields\n // read `facet.companyName`, and a link with no name renders as nothing.\n if (typeof input.companyName === 'string' && input.companyName.trim()) {\n out.companyName = input.companyName.trim().slice(0, 120)\n }\n if (typeof input.ownerUid === 'string' && input.ownerUid.trim()) {\n out.ownerUid = input.ownerUid.trim().slice(0, 128)\n }\n if (isContactLifecycleStage(input.lifecycleStage)) {\n out.lifecycleStage = input.lifecycleStage\n }\n if (input.custom && typeof input.custom === 'object') {\n /*\n * Only a key a field definition could have, and only a value the field\n * types can hold. A nested object here is a map the merge below would\n * write as a subtree nobody can render; a key with a dot in it would be\n * read as a PATH by the next dotted update to touch the facet. Nothing is\n * coerced — a door that has a number should send one — and an empty map\n * is left off rather than written as `{}` over a holder's values.\n */\n const custom: Record<string, ContactCustomValue> = {}\n for (const [key, value] of Object.entries(input.custom)) {\n if (!CONTACT_FIELD_KEY_PATTERN.test(key)) continue\n if (\n value === null ||\n typeof value === 'string' ||\n typeof value === 'number' ||\n typeof value === 'boolean'\n ) {\n custom[key] = typeof value === 'string' ? value.slice(0, 2000) : value\n }\n }\n if (Object.keys(custom).length) out.custom = custom\n }\n return out\n}\n\n/**\n * The org's companies collection, beside its contacts one.\n *\n * Reached through the contacts reference's parent — the org document —\n * because that is the one handle this function holds on the org. `parent`\n * is `null` only for a root collection, which an org subcollection never\n * is; answered as `null` rather than thrown so a count that has nowhere to\n * land is skipped, and never costs the capture.\n */\nfunction companiesBeside(\n contactsRef: FirebaseFirestore.CollectionReference,\n): FirebaseFirestore.CollectionReference | null {\n const parent = contactsRef.parent\n return parent ? parent.collection(CRM_COLLECTIONS.companies) : null\n}\n\n/**\n * Tags as the profile drawer stores them: trimmed, lowercased, deduplicated\n * and capped at twenty — so an imported `VIP` and a typed `vip` are one tag.\n */\nfunction normalizeTags(tags: readonly string[] | undefined): string[] {\n return [\n ...new Set(\n (tags ?? [])\n .map((tag) => String(tag ?? '').trim().toLowerCase())\n .filter(Boolean),\n ),\n ].slice(0, 20)\n}\n\n/**\n * Contacts ingestion (AGL-197): upserts an org-scoped contact doc (AGL-237)\n * keyed by normalized email from any capture point (forms, membership,\n * orders, bookings). Fire-and-forget by design — callers should never\n * fail their primary write because contact capture had a problem.\n *\n * Quota (AGL-890): contacts are audience BANDS, not hard caps. Paid plans\n * always create — contacts past the included band meter onto the monthly\n * invoice (report-usage cron). Free hard-bands at the included count:\n * only there do dropped creations increment `counters/contactsDropped`,\n * surfaced as a console alert (AGL-891). Interactions on existing\n * contacts always append regardless of plan.\n */\n/**\n * What a capture door learns when its capture made a NEW person.\n *\n * Handed to {@link UpsertHostContactOptions.onCreated} once, on the create\n * branch only. The merge branch is a visit by somebody the org already held,\n * which is another interaction and not a new contact — the same line\n * `campaignTouch` draws for attribution. Scalars and one string array, so\n * the runtime can flatten it into an event payload without inventing keys.\n */\nexport interface HostContactCreated {\n contactId: string\n hostId: string\n email: string\n name?: string\n source: ContactSource\n /** The capture surface's campaigns, normalized — `[]` when it had none. */\n campaignIds: string[]\n /**\n * The stage the create wrote onto the capturing facet — the door's\n * {@link UpsertHostContactOptions.initialLifecycleStage}, or the profile's\n * own — and absent when the capture named none, so an automation can\n * filter `lifecycleStage == \"lead\"` on the day the person appears.\n */\n lifecycleStage?: ContactLifecycleStage\n}\n\nexport interface UpsertHostContactOptions {\n hostId: string\n email: unknown\n name?: string\n source: ContactSource\n interaction: Omit<ContactInteraction, 'type' | 'atMs'> & { atMs?: number }\n /**\n * Explicit marketing opt-in (AGL-301) with a consent timestamp, recorded\n * against {@link hostId} — the brand whose form carried the checkbox — and\n * against the rest of its consent group only as far as\n * {@link disclosedConsentGroup} proves the group was shown.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` of the sentence the capture surface\n * rendered beside the checkbox (AGL-3320), as the surface sent it back.\n *\n * The grant pools across the site's consent group only when this is the\n * group's CURRENT key; absent, stale or mistyped, it is recorded for\n * {@link hostId} alone. Every surface that renders no disclosure passes\n * none, so its grant is one site's however the org is grouped. Only the\n * grant narrows: the record's `visibleTo` and its holder are the group's\n * either way, because who may see a person is not who may mail them.\n */\n disclosedConsentGroup?: string | null\n /**\n * An explicit REFUSAL, recorded against {@link hostId} (AGL-3185).\n *\n * Its own flag rather than `marketingConsent: false`, because every door\n * that passes a checkbox's value passes `false` for a box left alone, and\n * a box left alone records nothing — absence is the third state, and\n * turning it into a refusal would silently make every unticked visitor\n * unmailable. A door sets this only for an act of refusal: a switch turned\n * off, a prompt answered no. Ignored when {@link marketingConsent} is true.\n */\n declineMarketingConsent?: boolean\n /**\n * The provenance stored on the consent entry {@link marketingConsent} or\n * {@link declineMarketingConsent} writes (AGL-3185): who recorded it, which\n * door, which wording version. The console doors pass the person's own\n * (`actor: 'person'`); a site's capture surface passes none, which the\n * reader takes as the person's own act anyway.\n */\n marketingConsentSource?: MarketingConsentSource\n /**\n * Order value in cents — rolls into RFM fields (AGL-328).\n *\n * WHAT IT COUNTS (AGL-1748). GROSS of the platform fee and GROSS of\n * refunds — the money the customer handed over, not the money the merchant\n * kept. Every writer passes the same thing: whatever was actually charged\n * (`amount_total` for a Stripe path, `totals.totalCents` for POS), never a\n * figure re-derived from product docs, which is the AGL-1698/AGL-1711\n * lesson. Gross of the fee because this is a CUSTOMER attribute answering\n * \"what is this person worth to me?\", and the fee is a cost of the channel,\n * not something the buyer failed to spend.\n *\n * Refunds are still NOT netted here, and now they are recorded elsewhere\n * (AGL-1754). `refund.ts` writes `refundedCents`, `refundedOrdersCount` and\n * `lastRefundAtMs` BESIDE these fields — the shape AGL-1747 chose for the\n * same question on the orders CSV — rather than decrementing a stored number\n * whose meaning would then differ between rows written before and after that\n * commit. So `ltvCents` and `ordersCount` remain gross by definition, and a\n * READER that wants the net computes `ltvCents - refundedCents`, clamping\n * only what it ranks on: the difference can be negative for a customer whose\n * pre-AGL-1748 purchase was never counted and whose refund was, which is a\n * missing purchase showing itself rather than a corrupt contact. AGL-1753 is\n * the backfill that reconciles it. See `contact-refund.ts` in the commerce\n * plugin for the full reasoning and for why a refund never CREATES a contact.\n *\n * Passing 0 or omitting it means \"no purchase\": `ltvCents`, `ordersCount`,\n * `lastPurchaseAtMs` and `firstPurchaseAtMs` are all left untouched, which\n * is why a caller that formats the amount into the interaction summary and\n * forgets this field records a customer who has apparently never bought\n * anything.\n */\n purchaseCents?: number\n /**\n * The currency {@link purchaseCents} is in, lowercase, when the door knows.\n *\n * Absent everywhere today, because no order document carries a currency and\n * every checkout door writes `currency: 'usd'` onto the Stripe line items.\n * `attributeOrderToEmail` defaults it on that basis and says so. The field\n * exists so a door that ever charges in something else can pass it, and the\n * campaign revenue report keeps it in its own bucket rather than adding it\n * to the dollars.\n */\n purchaseCurrency?: string\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * ⛔ The ORDER path passes none, and must not start. An order already has\n * its own join one branch below — `attributeOrderToEmail`, keyed on the\n * order id — and a second record for the same sale would be the same money\n * counted twice under two rules. This is the door for the moments an order\n * does NOT cover: a form submission, a membership sign-up, a booking, a\n * newsletter capture.\n *\n * Resolved rather than raw, for the reason `addHostLead` states: one\n * visitor action reaches several writers and the touch lookup is paid once.\n */\n campaignTouch?: ResolvedCampaignTouch | null\n /**\n * The campaigns the CAPTURE SURFACE is filed under.\n *\n * ⚠️ A different fact from {@link campaignTouch} beside it, and the two must\n * never be folded together. A touch is where the visitor came FROM — an ad,\n * a link, a browser-supplied label resolved through an allowlist. This is\n * which campaigns the merchant put the form itself in, which is the\n * merchant's own act and is true of everybody who fills that form in,\n * including the visitor who arrived by typing the address.\n *\n * ⛔ And it is not consent. Filing a form under a campaign says nothing\n * about what the person agreed to; `marketingConsent` above is the only\n * input that records a basis.\n */\n campaignIds?: readonly string[]\n /**\n * Tags to put on THIS holder's facet (AGL-2602).\n *\n * Added to, never replaced, on a merge: a person the merchant tagged by\n * hand and later imported keeps the hand-written tag beside the file's.\n */\n tags?: readonly string[]\n /**\n * The profile a door knows about the person — phone, title, company,\n * address, owner, stage, custom values — written into THIS holder's facet\n * (AGL-2602). Only the keys present are written, so a door that knows the\n * phone and nothing else does not blank the title somebody typed.\n */\n facet?: UpsertHostContactFacet\n /**\n * The EARLIEST stage that describes what this capture was (AGL-2612).\n *\n * Every door names one: a form submission is a `lead`, a newsletter opt-in\n * or a member sign-up is a `subscriber`, a purchase — an order, a paid\n * booking — is a `customer`. The rule applied to it is\n * `advanceContactLifecycleStage`: it fills an empty stage and advances an\n * earlier one, and never moves anybody back, so a customer who fills in\n * the contact form is still a customer and a subscriber who then submits\n * a form becomes a lead. Applied on top of `facet.lifecycleStage` when a\n * door carries both — the caller's stage is the base and this is its\n * floor.\n *\n * A FLOOR and not a value, which is why it is not the facet field: the\n * facet's `lifecycleStage` is a SET, the shape the console's create\n * drawer and the import need — the merchant typed a stage and that is the\n * stage — and a capture door writing a set would put every returning\n * customer back to `lead` on their next enquiry. Omitted by the doors\n * that carry the caller's own stage or none (manual, import, API, a lead\n * conversion), whose contacts read as \"no stage\", which is true.\n */\n initialLifecycleStage?: ContactLifecycleStage\n /**\n * Told when this capture created a contact (AGL-2605).\n *\n * A HOOK rather than an event emitted from here, and the reason is the\n * dependency direction: the event fan-out lives in `libs/tenant/runtime`,\n * which imports THIS library for its Firestore handle and its org helpers.\n * An import back up from here would be a cycle, and the module boundaries\n * (`scope:data` may depend on data and util only) refuse it besides. So\n * this module reports the fact and the runtime's `captureHostContact`\n * turns it into `contactCreated` — every server door goes through that\n * wrapper, and a door that calls this function directly has chosen to\n * create contacts nothing can react to.\n *\n * Awaited with its own catch, like the order join above: the least\n * important write on the path, and a failure in it must not cost the\n * capture that already happened.\n */\n onCreated?: (created: HostContactCreated) => void | Promise<void>\n}\n\nexport async function upsertHostContact(\n options: UpsertHostContactOptions,\n): Promise<UpsertHostContactVerdict> {\n try {\n const email = normalizeContactEmail(options.email)\n if (!email) return { refused: 'invalid-email' }\n const tags = normalizeTags(options.tags)\n\n /*==========================================\n * THE PURCHASE DOOR, AND THEREFORE THE ATTRIBUTION DOOR.\n *\n * Every way of buying something in this product — the cart, buy-now, the\n * POS register, a draft order, a reservation, a subscription renewal, a\n * booking — announces itself here, in exactly one shape: source `order`,\n * a `purchaseCents` amount, and a `refId` naming what was bought. That\n * shape IS the purchase chokepoint, which is why the revenue join hangs\n * off it rather than off seven call sites in a webhook.\n *\n * ABOVE the audience-band gate below, and deliberately. Contact creation\n * is band gated, so a Free org past its included count drops the CRM\n * record — and an attribution written inside that branch would drop the\n * revenue with it. The join keys on the address hash, exactly as the\n * touch and the suppression list do, so it never needs a contact document\n * to exist: a guest checkout by somebody who is not and never becomes a\n * contact still credits the campaign whose link they clicked.\n *\n * Its own `catch`, inside a function that already swallows: this is the\n * least important write on the path, and a failure here must not cost the\n * contact capture below it.\n *=========================================*/\n if (options.source === 'order' && options.interaction.refId) {\n await attributeOrderToEmail({\n hostId: options.hostId,\n orderId: String(options.interaction.refId),\n email,\n amountCents: Number(options.purchaseCents ?? 0),\n ...(options.purchaseCurrency\n ? { currency: options.purchaseCurrency }\n : {}),\n orderedAtMs: options.interaction.atMs ?? Date.now(),\n }).catch(() => null)\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const hostRef = firestore.collection('hosts').doc(options.hostId)\n // Contacts are org-scoped (AGL-237): every host in the org feeds one\n // shared list.\n const contactsRef = await orgDataCollectionForHost(\n options.hostId,\n 'contacts',\n )\n /*\n * The consent group this capture belongs to — the sites declared to be\n * one sender, or this site alone. Resolved once and used for three\n * different decisions below, which must all agree: which controller the\n * basis is recorded for, which sites the row becomes visible to, and\n * which holder the profile is filed under.\n */\n const group = await consentGroupForSite(options.hostId)\n /*\n * THE CONSENT ENTRY THIS CAPTURE WRITES, decided once for both branches.\n *\n * A grant outranks a refusal flag set beside it — one door cannot mean\n * both — and neither writes anything when the door carried no act, so\n * an unticked box stays the third state. The provenance rides inside the\n * per-host entry, where the reader looks for it, never at the top of a\n * document every brand in the org shares.\n *\n * The grant covers the group only when the capture surface proved it\n * showed the group's current disclosure — `consentGroupForGrant` answers\n * the site alone for every other capture. The visibility and the holder\n * below keep the whole group.\n */\n const consentExtra = options.marketingConsentSource\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: options.marketingConsentSource }\n : undefined\n const consentFields = options.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, options.disclosedConsentGroup),\n Date.now(),\n consentExtra,\n )\n : options.declineMarketingConsent\n ? declineMarketingConsentFields(options.hostId, Date.now(), consentExtra)\n : {}\n const interaction: ContactInteraction = {\n type: options.source,\n atMs: options.interaction.atMs ?? Date.now(),\n // WHICH SITE this visit happened on. The row is shared; the history on\n // it is not, and a timeline with no site cannot be split for an\n // agency's client without showing them another client's activity.\n hostId: options.hostId,\n ...(options.interaction.refId\n ? { refId: options.interaction.refId }\n : {}),\n ...(options.interaction.summary\n ? { summary: options.interaction.summary.slice(0, 200) }\n : {}),\n // The entry point, when the door knows it. Written only when present:\n // an absent field is a door that has none, and Firestore rejects\n // `undefined` inside an array element outright.\n ...(options.interaction.formId\n ? { formId: String(options.interaction.formId).slice(0, 128) }\n : {}),\n ...(options.interaction.path\n ? { path: String(options.interaction.path).slice(0, 500) }\n : {}),\n }\n\n /*\n * THE CAMPAIGNS THIS CAPTURE FILES THE PERSON UNDER.\n *\n * Normalized here rather than trusted, because it reaches this function\n * from a public endpoint's document read and every reader of the stored\n * array goes through the same coercion.\n */\n const campaignIds = normalizeCampaignIds(options.campaignIds ?? [])\n /*\n * THE CUSTOM FIELD VALUES THIS CAPTURE CARRIES, as one nested map.\n *\n * Only the keys the door resolved. Written in the NESTED form because both\n * writes below are merge-sets, which deep-merge a map one key at a time:\n * `custom: { tier: 'Gold' }` lands beside an existing `custom.vip` and\n * leaves it standing. A dotted `facets.h1.custom.tier` path would be a\n * literal field name to a `set`, and a `custom` written whole would take\n * every other key with it.\n */\n const customEntries = Object.entries(options.facet?.custom ?? {})\n const customFacet = customEntries.length\n ? { custom: Object.fromEntries(customEntries) }\n : {}\n\n /*==========================================\n * THE DEDUPE LOOKUP IS UNSCOPED, AND HAS TO BE.\n *\n * One human who touched two sites is ONE person. Narrowing this read to\n * what the capturing site may already see would make a second submission\n * on a sibling brand create a SECOND document for the same address —\n * which loses the dedupe the shared address book exists for, and bills\n * the org twice for one human.\n *\n * Recognizing somebody is not the same act as being allowed to read their\n * row, and the two were the same query while every contact was stamped\n * org-wide. They are separated here: this finds the person, and\n * `visibleTo` below decides who may see them — widened by the capture\n * that just happened, never by the lookup that found them.\n *\n * THROUGH THE ADDRESS INDEX FIRST (AGL-2625). A person whose two records\n * were merged answers to two addresses, and only one of them is the\n * document's `email`; the index is what lets a capture on the other one\n * land on the survivor instead of minting the duplicate again. The query\n * stays as the fallback, so a row the index has not seen costs one extra\n * read once and needs no backfill.\n *=========================================*/\n const docSnapshot = await findContactByEmail(contactsRef, email)\n\n if (docSnapshot) {\n /*\n * MERGED INTO THIS GROUP'S FACET, not into the top of the document.\n *\n * `sources`, `interactions`, the tags, the notes and every commercial\n * figure are the HOLDER's own business records: a booking taken by one\n * client of an agency is that client's, and while these lived at the\n * top of a shared row every other client could read them. The identity\n * — the address, and a canonical name for a holder that has set none of\n * its own — stays shared, because that is what makes this one row.\n */\n const facet = readContactFacet(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const merged = mergeContactInteraction(\n {\n name: facet.name ?? undefined,\n sources: facet.sources,\n interactions: facet.interactions,\n },\n { source: options.source, interaction, name: options.name },\n )\n /*\n * THE PROFILE, as this door knows it (AGL-2596).\n *\n * Given keys only, so the merge below leaves untouched whatever another\n * door wrote. The stage is the one field with a rule of its own: the\n * door's `initialLifecycleStage` fills an empty stage and advances an\n * earlier one, and never moves anybody back —\n * `advanceContactLifecycleStage` is that rule, applied to the stage\n * this door asked for outright or, failing that, the one already\n * stored. Written only when it comes back with something: a door that\n * named no stage and found none leaves the key absent, which reads as\n * \"no stage\" rather than as a stage somebody picked.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage ?? facet.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n /*\n * THE FORM MIRROR, bounded (see `HostContact.formIds`).\n *\n * Read off the document the lookup already fetched, so the bound costs\n * no extra read: a person who has come in through twenty forms keeps\n * the twenty, and this capture's form stays on the interaction alone.\n * `arrayUnion` for the add, so a concurrent capture on a sibling site\n * cannot drop this one's id.\n */\n const heldFormIds: unknown[] = Array.isArray(\n docSnapshot.get(CONTACT_FORM_IDS_FIELD),\n )\n ? docSnapshot.get(CONTACT_FORM_IDS_FIELD)\n : []\n const formIdToAdd =\n interaction.formId &&\n !heldFormIds.includes(interaction.formId) &&\n heldFormIds.length < CONTACT_FORM_IDS_CAP\n ? interaction.formId\n : null\n /*\n * THE COMPANY LINK, when the door carried one (AGL-2613).\n *\n * The facet's `companyId` is written with the rest of the profile\n * below, but the facet is not the whole association: the top-level\n * `companyIds` mirror is what the company page queries, and the\n * company's `contactsCount` is what the companies list shows. The\n * planner reads the row as it stands — this holder's previous link,\n * the mirror, the other holders' ids — and says what the mirror\n * becomes and which counts move; the mirror change rides in this same\n * merge-set, and the counts settle after it.\n */\n const link =\n profile.companyId !== undefined\n ? planContactCompanyLink(\n readContactCompanyLink(\n docSnapshot.data() as Record<string, unknown>,\n group.groupId,\n ),\n profile.companyId,\n )\n : null\n const mirror = link ? contactCompanyMirrorValue(link) : undefined\n await docSnapshot.ref.set(\n {\n // The search keys travel WITH the name, and only when the name is\n // written: stamping an empty key over a real one would make the\n // contact unfindable by the name it still displays.\n ...(merged.name ? nameSearchFields(merged.name) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The search echo of the facet's company name — see `HostContact.companyName`.\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n /*\n * NESTED, not dot-pathed. This is a `set(…, { merge: true })`, and\n * a merge-set treats a key containing dots as a literal field name\n * — only `update()` reads them as paths. The nested form is what\n * Firestore deep-merges, so this writes one holder's facet and\n * leaves every other holder's untouched.\n */\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: merged.sources,\n interactions: merged.interactions,\n ...(merged.name ? { name: merged.name } : {}),\n /*\n * ADDED TO, never replaced. A person who filled in the spring\n * form and later the summer one is in both pushes, and an\n * assignment that overwrote would take a campaign a merchant\n * filed them under back out with nothing on screen to say so —\n * the reason `campaign-membership.ts` made the field an array\n * and the automation step has always used `arrayUnion`.\n *\n * Nested under the group id rather than written at\n * `contactCampaignFieldPath`, because this is a merge-`set`: a\n * `set` treats a dotted string as a literal field NAME and would\n * mint a top-level key with dots in it. Only `update()` reads\n * dots as a path. The nested form is what Firestore deep-merges,\n * so it reaches one holder's facet and leaves every other\n * holder's alone — the same guarantee the dotted path gives the\n * automation step, in the shape this write is allowed to take.\n */\n ...(campaignIds.length\n ? { campaignIds: FieldValue.arrayUnion(...campaignIds) }\n : {}),\n // The same union rule as the campaigns above it, for the same\n // reason: a tag the merchant put on by hand survives the file.\n ...(tags.length ? { tags: FieldValue.arrayUnion(...tags) } : {}),\n // Present keys only, deep-merged by the merge-set — so the\n // fields this door did not carry keep whatever they held.\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: FieldValue.increment(options.purchaseCents),\n ordersCount: FieldValue.increment(1),\n lastPurchaseAtMs: Date.now(),\n // A contact that EXISTED before their first purchase\n // reached this branch, and it never wrote\n // `firstPurchaseAtMs` — only the create path did. So\n // every converted lead permanently lacked RFM's R anchor\n // while walk-in buyers carried it. Set it on the first\n // purchase only; later purchases must not move it.\n ...(facet.firstPurchaseAtMs\n ? {}\n : { firstPurchaseAtMs: Date.now() }),\n }\n : {}),\n },\n },\n /*\n * ATTRIBUTION GROWS ON THE MERGE BRANCH, which the create-only\n * `hostId` beside it never did — so a person the first site\n * captured and the second site later met read as the first site's\n * alone, forever.\n *\n * `arrayUnion`, so the audience filter \"everyone captured on A, B\n * or C\" answers with the sites that actually met this person.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(options.hostId),\n ...(formIdToAdd\n ? { [CONTACT_FORM_IDS_FIELD]: FieldValue.arrayUnion(formIdToAdd) }\n : {}),\n /*\n * AND SO DOES VISIBILITY — by the capture, never by the lookup.\n *\n * This site just collected this person: it has its own relationship\n * with them and may see the row. A site that has never captured\n * them gains nothing here, which is what keeps an agency's clients\n * apart on a document all of them share. The per-interaction\n * `hostId` above is what keeps the HISTORY apart on the same row.\n */\n visibleTo: FieldValue.arrayUnion(...consentGroupScope(group)),\n /*\n * RECORDED AGAINST THE CAPTURING SITE, not against the org.\n *\n * The contact document is shared by every site in the org, which is\n * the point of it — one address book behind many brands. Its\n * consent is not shared on the same terms: the checkbox this\n * capture carried was ticked under one brand's name, on one brand's\n * form, and a basis written at the top of this document made the\n * person mailable by every other brand the account holds.\n *\n * A merge writes one key of the map and leaves the rest, so a\n * person who opts in to a second site accumulates two grants rather\n * than replacing the first.\n */\n ...consentFields,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n await settleCompanyContactsCounts(companiesBeside(contactsRef), link)\n // The merged facet, name and scope are what the Contacts list\n // searches and filters by (AGL-3321): restamped from the stored row.\n await restampCrmListFieldsAt(docSnapshot.ref, 'contacts')\n return { contactId: docSnapshot.id, created: false }\n }\n\n /*\n * THE ERASED PERSON DOES NOT COME BACK (AGL-2623).\n *\n * Before the band, on the CREATE branch only — the one read this adds\n * is paid by a capture that would otherwise make a new row, never by\n * the merge above. An erasure wrote a row on this site's suppression\n * list with no address on it; finding that row here is what keeps the\n * next form fill or checkout from rebuilding the record the workspace\n * just erased. The order or booking that triggered the capture still\n * succeeds, exactly as it does when the band refuses: the person bought\n * something, and the merchant's record of the sale is not the CRM's\n * record of the person.\n */\n if (await hostRefusesCaptureForErasure(options.hostId, email, firestore)) {\n return { refused: 'erased' }\n }\n\n // New contact: records-band check via the aggregate counts (cheap; no\n // doc reads) against the owning org's entitlements (AGL-238/890).\n // Metered plans always pass (overage bills via the report-usage\n // cron); only free's hard band drops the CRM record — visibly, via\n // the counter and the console alert (AGL-891). The signup/order that\n // triggered the capture always succeeds either way.\n //\n // THE RECORDS BAND, not the contacts headcount (AGL-2611): companies\n // and deals share this band, so a capture is refused on a Free org\n // whose hundred records are ninety companies and ten people — the same\n // verdict the company drawer would give the ninety-first company.\n // `contactsRef` is handed in so the contacts aggregate is the one this\n // door already reads; the org reference is its parent.\n const orgBilling = await getOrgForHost(options.hostId)\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const { crmRecordsCount } = await countCrmRecords(orgRef, contactsRef)\n const quota = checkCrmRecordsQuota(\n (orgBilling?.org as any) ?? null,\n crmRecordsCount,\n )\n if (!quota.allowed) {\n await hostRef\n .collection('counters')\n .doc('contactsDropped')\n .set({ total: FieldValue.increment(1) }, { merge: true })\n return { refused: 'band' }\n }\n\n /*\n * The profile on a create, with the door's stage floor applied the same\n * way as on a merge — there is no stored stage yet, so the floor is what\n * a door that named one writes. A `null` address is dropped rather than\n * written: there is nothing on a new document for it to clear.\n */\n const profile = storableProfile(options.facet)\n const advanced = advanceContactLifecycleStage(\n profile.lifecycleStage,\n options.initialLifecycleStage,\n )\n if (advanced) profile.lifecycleStage = advanced\n if (profile.address === null) delete profile.address\n\n const record: Record<string, unknown> = {\n hostId: options.hostId,\n /*\n * WHICH SITES HAVE MET THIS PERSON — attribution, and separate from\n * consent above.\n *\n * The scalar `hostId` beside it names the FIRST capturing site and is\n * never rewritten, which is provenance for the ROW. This array is the\n * one that grows and the one an audience query can filter on.\n */\n [CAPTURED_BY_HOST_FIELD]: [options.hostId],\n // The form mirror's first entry, when this capture came through one —\n // see `HostContact.formIds`. Absent otherwise, like the campaigns.\n ...(interaction.formId\n ? { [CONTACT_FORM_IDS_FIELD]: [interaction.formId] }\n : {}),\n /*\n * THE CAPTURING GROUP, not the whole org.\n *\n * Stamping `['org']` here made every contact readable by every site in\n * the account on the day it was created — so an agency's twelve clients\n * shared one address book by default, and closing the missing-field\n * fail-open would not have touched it, because the field was present\n * and said so.\n *\n * A group of one — the default — is this site alone. A declared group\n * is the sites that already present as one sender. Widening beyond that\n * is available and is an ACT: an org may set `defaultResourceScope` to\n * `'org'`, or a later capture on a sibling site unions that site in.\n */\n visibleTo:\n (orgBilling?.org as { defaultResourceScope?: 'org' | 'host' } | null)\n ?.defaultResourceScope === 'org'\n ? [ORG_SCOPE_TOKEN]\n : consentGroupScope(group),\n email,\n ...(options.name ? nameSearchFields(options.name.slice(0, 120)) : {}),\n // The search echo of the facet's phone — see `HostContact.phone`.\n ...(profile.phone ? { phone: profile.phone } : {}),\n // The search echo of the facet's company name — see `HostContact.companyName`.\n ...(profile.companyName ? { companyName: profile.companyName } : {}),\n // The company mirror the company page queries (AGL-2613) — a create\n // has nothing to union with, so the door's company is the whole list.\n ...(profile.companyId\n ? { [CONTACT_COMPANY_IDS_FIELD]: [profile.companyId] }\n : {}),\n // The facet this capture creates. Everything a holder owns lives under\n // its own group id; the address and the canonical name above are the\n // only shared identity.\n [CONTACT_FACETS_FIELD]: {\n [group.groupId]: {\n sources: { [options.source]: true },\n interactions: [interaction],\n tags,\n // A create has nothing to union with, so the normalized list is the\n // whole membership.\n ...(campaignIds.length ? { campaignIds } : {}),\n ...customFacet,\n ...(options.name\n ? { name: options.name.slice(0, 120) }\n : {}),\n ...profile,\n ...(options.purchaseCents\n ? {\n ltvCents: options.purchaseCents,\n ordersCount: 1,\n lastPurchaseAtMs: Date.now(),\n firstPurchaseAtMs: Date.now(),\n }\n : {}),\n },\n },\n ...consentFields,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await contactsRef.add({\n ...record,\n // What the Contacts list searches and filters by (AGL-3321), from the\n // row as written — and nothing scheduled against a new person.\n ...crmNewRecordListFields('contacts', record),\n })\n // The address now resolves to this row (AGL-2625). Its own catch inside\n // the writer, so an index the capture could not reach costs nothing.\n await writeContactEmailIndex(emailIndexBeside(contactsRef), created.id, [email])\n // The company the door named now has one more contact naming it\n // (AGL-2613) — the fresh row's plan is the trivial one, nothing held\n // before and nothing held elsewhere.\n if (profile.companyId) {\n await settleCompanyContactsCounts(\n companiesBeside(contactsRef),\n planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n profile.companyId,\n ),\n )\n }\n /*\n * ATTRIBUTED ON CREATION ONLY, and therefore below the band gate rather\n * than above it — the opposite placement to the order join at the top of\n * this function, deliberately.\n *\n * The order join sits above because it credits MONEY, which is real\n * whether or not a CRM record was kept for the buyer, and a Free org past\n * its included band would otherwise silently drop the revenue with the\n * contact. This credits a contact, which does not exist when the band\n * dropped it: attributing one above the gate would report a campaign as\n * having produced people the customer cannot see anywhere in the console.\n *\n * The existing-contact branch above returns without reaching here, which\n * is the same rule `addHostLead` applies — an interaction appended to\n * somebody the site already held is another visit, not a new person, and\n * crediting it would let the most recent campaign re-earn the whole list.\n */\n if (options.campaignTouch) {\n await attributeCampaignConversion({\n hostId: options.hostId,\n kind: 'contact',\n refId: created.id,\n touch: options.campaignTouch,\n convertedAtMs: interaction.atMs,\n })\n }\n /*\n * REPORTED ON CREATION ONLY, below the band gate for the reason the\n * attribution above is: a contact the band dropped does not exist, and\n * an automation told about it would act on a person the console cannot\n * show. The merge branch returned long before this line, so a repeat\n * visit by somebody already held is never announced as a new contact.\n */\n if (options.onCreated) {\n await Promise.resolve(\n options.onCreated({\n contactId: created.id,\n hostId: options.hostId,\n email,\n ...(options.name ? { name: options.name.slice(0, 120) } : {}),\n source: options.source,\n campaignIds,\n ...(profile.lifecycleStage\n ? { lifecycleStage: profile.lifecycleStage }\n : {}),\n }),\n ).catch((error) => {\n console.error('upsertHostContact onCreated failed', error)\n })\n }\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('upsertHostContact failed', error)\n return { refused: 'error' }\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkCrmRecordsQuota","consentGroupScope","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","marketingConsentFieldsForGroup","mergeContactInteraction","normalizeCampaignIds","readContactFacet","normalizeContactEmail","ORG_SCOPE_TOKEN","FieldValue","firebaseAdmin","countCrmRecords","restampCrmListFieldsAt","attributeOrderToEmail","hostRefusesCaptureForErasure","attributeCampaignConversion","nameSearchFields","consentGroupForGrant","declineMarketingConsentFields","MARKETING_CONSENT_SOURCE_FIELD","advanceContactLifecycleStage","CONTACT_COMPANY_IDS_FIELD","CONTACT_FIELD_KEY_PATTERN","CRM_COLLECTIONS","crmNewRecordListFields","isContactLifecycleStage","planContactCompanyLink","readContactCompanyLink","normalizeAddress","normalizePhone","contactCompanyMirrorValue","settleCompanyContactsCounts","emailIndexBeside","findContactByEmail","writeContactEmailIndex","consentGroupForSite","getOrgForHost","orgDataCollectionForHost","storableProfile","input","out","phone","undefined","jobTitle","trim","slice","leadSource","replace","address","companyId","companyName","ownerUid","lifecycleStage","custom","key","value","Object","entries","test","keys","length","companiesBeside","contactsRef","parent","collection","companies","normalizeTags","tags","Set","map","tag","String","toLowerCase","filter","Boolean","upsertHostContact","options","orgBilling","email","refused","source","interaction","refId","hostId","orderId","amountCents","Number","purchaseCents","purchaseCurrency","currency","orderedAtMs","atMs","Date","now","catch","firestore","app","hostRef","doc","group","consentExtra","marketingConsentSource","consentFields","marketingConsent","disclosedConsentGroup","declineMarketingConsent","type","summary","formId","path","campaignIds","customEntries","facet","customFacet","fromEntries","docSnapshot","profile","data","groupId","merged","name","sources","interactions","advanced","initialLifecycleStage","heldFormIds","Array","isArray","get","formIdToAdd","includes","link","mirror","ref","set","arrayUnion","ltvCents","increment","ordersCount","lastPurchaseAtMs","firstPurchaseAtMs","visibleTo","updatedAt","serverTimestamp","merge","contactId","id","created","orgRef","orgId","crmRecordsCount","quota","org","allowed","total","record","defaultResourceScope","createdAt","add","companyIds","heldElsewhere","campaignTouch","kind","touch","convertedAtMs","onCreated","Promise","resolve","error","console"],"mappings":"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"}