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

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 (96) hide show
  1. package/package.json +8 -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/dns-probe.d.ts +7 -0
  25. package/src/lib/server/dns-probe.js +52 -0
  26. package/src/lib/server/dns-probe.js.map +1 -1
  27. package/src/lib/server/duplicate-resource.js +12 -5
  28. package/src/lib/server/duplicate-resource.js.map +1 -1
  29. package/src/lib/server/email-deliverability.d.ts +150 -0
  30. package/src/lib/server/email-deliverability.js +538 -0
  31. package/src/lib/server/email-deliverability.js.map +1 -0
  32. package/src/lib/server/email-delivery-log.d.ts +33 -0
  33. package/src/lib/server/email-delivery-log.js +43 -2
  34. package/src/lib/server/email-delivery-log.js.map +1 -1
  35. package/src/lib/server/email-sender-reputation.d.ts +2 -11
  36. package/src/lib/server/email-sender-reputation.js +8 -25
  37. package/src/lib/server/email-sender-reputation.js.map +1 -1
  38. package/src/lib/server/email-suppression.d.ts +21 -15
  39. package/src/lib/server/email-suppression.js +18 -34
  40. package/src/lib/server/email-suppression.js.map +1 -1
  41. package/src/lib/server/erase-person.js +53 -6
  42. package/src/lib/server/erase-person.js.map +1 -1
  43. package/src/lib/server/erase.js +4 -3
  44. package/src/lib/server/erase.js.map +1 -1
  45. package/src/lib/server/host-memberships.d.ts +34 -0
  46. package/src/lib/server/host-memberships.js +89 -1
  47. package/src/lib/server/host-memberships.js.map +1 -1
  48. package/src/lib/server/host-visitor-records.d.ts +16 -2
  49. package/src/lib/server/host-visitor-records.js +52 -7
  50. package/src/lib/server/host-visitor-records.js.map +1 -1
  51. package/src/lib/server/hosted-page-review.d.ts +92 -0
  52. package/src/lib/server/hosted-page-review.js +342 -0
  53. package/src/lib/server/hosted-page-review.js.map +1 -0
  54. package/src/lib/server/list-members.d.ts +11 -0
  55. package/src/lib/server/list-members.js +28 -1
  56. package/src/lib/server/list-members.js.map +1 -1
  57. package/src/lib/server/list-query.d.ts +36 -0
  58. package/src/lib/server/list-query.js +49 -0
  59. package/src/lib/server/list-query.js.map +1 -0
  60. package/src/lib/server/media-tombstone.js +8 -2
  61. package/src/lib/server/media-tombstone.js.map +1 -1
  62. package/src/lib/server/org-age.d.ts +27 -0
  63. package/src/lib/server/org-age.js +44 -0
  64. package/src/lib/server/org-age.js.map +1 -0
  65. package/src/lib/server/org-member-list-fields.d.ts +31 -0
  66. package/src/lib/server/org-member-list-fields.js +38 -0
  67. package/src/lib/server/org-member-list-fields.js.map +1 -0
  68. package/src/lib/server/organizations.js +37 -7
  69. package/src/lib/server/organizations.js.map +1 -1
  70. package/src/lib/server/outbound-send-review.d.ts +222 -0
  71. package/src/lib/server/outbound-send-review.js +479 -0
  72. package/src/lib/server/outbound-send-review.js.map +1 -0
  73. package/src/lib/server/payment-fraud-signal.d.ts +156 -0
  74. package/src/lib/server/payment-fraud-signal.js +368 -0
  75. package/src/lib/server/payment-fraud-signal.js.map +1 -0
  76. package/src/lib/server/payment-risk-record.d.ts +59 -0
  77. package/src/lib/server/payment-risk-record.js +76 -0
  78. package/src/lib/server/payment-risk-record.js.map +1 -0
  79. package/src/lib/server/replay-erasures.js +2 -1
  80. package/src/lib/server/replay-erasures.js.map +1 -1
  81. package/src/lib/server/sending-domains.d.ts +6 -0
  82. package/src/lib/server/sending-domains.js +98 -6
  83. package/src/lib/server/sending-domains.js.map +1 -1
  84. package/src/lib/server/sso-enforcement.js +2 -1
  85. package/src/lib/server/sso-enforcement.js.map +1 -1
  86. package/src/lib/server/stripe-card-authentication.d.ts +35 -0
  87. package/src/lib/server/stripe-card-authentication.js +63 -0
  88. package/src/lib/server/stripe-card-authentication.js.map +1 -0
  89. package/src/lib/server/tenant-write-lockdown.d.ts +32 -2
  90. package/src/lib/server/tenant-write-lockdown.js +33 -3
  91. package/src/lib/server/tenant-write-lockdown.js.map +1 -1
  92. package/src/lib/server/upsert-contact.js +8 -4
  93. package/src/lib/server/upsert-contact.js.map +1 -1
  94. package/src/tenancy.d.ts +3 -0
  95. package/src/tenancy.js +8 -0
  96. package/src/tenancy.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/tenant-write-lockdown.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 * The lockdown gate for VISITOR writes on a customer's live site\n * (AGL-1511) — form submissions, cart changes, checkout.\n *\n * Why this is its own chokepoint rather than the console's. The tenant\n * middleware, which is where AGL-1501 enforces, **does not match `/api`**\n * (see the matcher in apps/tenant/middleware.ts): it exists to stop CACHED\n * PAGES serving. So the site's own write endpoints have had no lockdown\n * enforcement at all — under a full org takedown the pages 503 while a form\n * POST still writes. This closes that for both modes.\n *\n * The product decision this file encodes, from the issue: a visitor-facing\n * write on a read-serving site gets a **polite inline pause, never a\n * page-level 503**. A customer's site staying up and earning is the entire\n * reason read-only mode exists instead of full lockdown, and answering their\n * visitors with an outage page would spend exactly what the mode was\n * protecting. So the refusal is a 423 with visitor copy the block renders\n * inline, and the page around it never stops rendering.\n *\n * Under a FULL lock the same 423 is returned rather than a 503, and that is\n * deliberate too: the middleware has already replaced the whole page for\n * anyone loading it, so a write arriving here is a stale tab or a script,\n * and a machine-readable refusal is more useful to both than an HTML page.\n *\n * NO staff bypass and NO user scope, matching /api/lockdown-verdict: this is\n * a public website, the caller is anonymous, and a visitor's identity plays\n * no part in whether a site accepts an order.\n */\n\nimport {\n isLockdownActive,\n lockdownBlocks,\n type LockdownIntent,\n lockdownIntentForMethod,\n type LockdownPausedSurface,\n lockdownPausedNotice,\n type LockdownState,\n normalizeHostLockdown,\n normalizeOrgLockdown,\n resolveLockdown,\n} from '@aglyn/aglyn/server'\nimport {\n getPlatformLockdown,\n heldSiteTakedown,\n lockdownJsonResponse,\n rememberSiteTakedown,\n} from './lockdown'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\n\n/**\n * The active platform/org/host lockdown for one site, or null.\n *\n * Both reads are already paid by the callers on this path — the dispatcher\n * reads the host doc for its plugin deny-list and the org doc for\n * enablement, and both getters are `React.cache`-deduped per request — so\n * the gate costs one TTL-cached platform read and nothing else.\n *\n * FAIL OPEN on any error, matching every other lockdown reader: an\n * unreachable Firestore is an outage, not a lockdown, and a shop that stops\n * taking orders because a read timed out is a worse failure than the one\n * being guarded against.\n *\n * …EXCEPT for a takedown (AGL-1881, extending AGL-1621). One class of lock\n * inverts that cost function — \"the database was down\" is not an answer to a\n * court order — and until now the ledger that carries it covered only the\n * four scopes whose lock lives in `lockdowns/*`. Org and host locks live on\n * the org/host document, so the two scopes MOST likely to be carrying a\n * legal order were the two that fell through. Every successful read now\n * records its org and host verdict, and a failed read serves back whichever\n * of them was a takedown and is still active.\n *\n * Note what is NOT done here: the catch does not fail closed on the state it\n * could not read. On a read failure `suspendedEnforcement` is exactly as\n * unreadable as everything else, which is the whole reason a separate\n * durable ledger exists — and a catch that locked unconditionally would take\n * every shop on the platform offline on any transient blip, which is the\n * deliberate posture this keeps for the default case.\n */\nexport async function getSiteLockdown(\n hostId: string,\n nowMs = Date.now(),\n): Promise<LockdownState | null> {\n if (!hostId) return null\n try {\n const [platform, resolved, host] = await Promise.all([\n getPlatformLockdown(),\n getOrgForHost(hostId),\n getHostDocAdmin(hostId),\n ])\n const org = normalizeOrgLockdown(resolved?.org as never)\n const hostState = normalizeHostLockdown(host as never)\n rememberSiteTakedown(hostId, { org, host: hostState })\n const state = resolveLockdown({ platform, org, host: hostState }, nowMs)\n return isLockdownActive(state, nowMs) ? state : null\n } catch (error) {\n console.error('[tenant-write-lockdown] verdict failed', hostId, error)\n const held = heldSiteTakedown(hostId, nowMs)\n // The shipped fail-open answer, returned by the same statement it always\n // was, for every case where nothing takedown-class was ever observed.\n if (!held.org && !held.host) return null\n // The platform read is TTL-cached and swallows its own errors (it carries\n // its own ledger entry), so asking again here costs nothing and keeps a\n // wider lock from being masked by a narrower held one.\n const platform = await getPlatformLockdown().catch(() => null)\n const state = resolveLockdown(\n { platform, org: held.org, host: held.host },\n nowMs,\n )\n return isLockdownActive(state, nowMs) ? state : null\n }\n}\n\n/**\n * The refusal a visitor-facing write route returns, or null to proceed:\n *\n * const paused = await visitorWriteRefusal({ hostId, request, surface })\n * if (paused) return paused\n *\n * `surface` picks the WORDS only (`lockdownPausedNotice`) — the checkout\n * sentence has to promise no card was charged, and no generic copy can.\n */\nexport async function visitorWriteRefusal(options: {\n hostId: string\n /** The request whose method decides read vs write. */\n request?: { method?: string } | null\n /** Overrides `request`; `write` when neither is given. */\n intent?: LockdownIntent\n surface: LockdownPausedSurface\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const intent =\n options.intent ??\n (options.request ? lockdownIntentForMethod(options.request.method) : 'write')\n // This is a WRITE gate: reads leave before any Firestore read, so the hot\n // unauthenticated GET path pays nothing. A full lock's refusal of tenant\n // API *reads* is not attempted here and is unchanged from today — the\n // middleware owns the page surface, and a read-refusing API gate is its\n // own decision with its own blast radius (filed separately).\n if (intent === 'read') return null\n const state = await getSiteLockdown(options.hostId, nowMs)\n if (!lockdownBlocks(state, intent)) return null\n return lockdownJsonResponse(state as LockdownState, {\n notice: lockdownPausedNotice(options.surface),\n })\n}\n\n/**\n * The refusal a visitor-facing route that SERVES SITE CONTENT over `/api`\n * returns under a FULL lock, or null to proceed (AGL-2495):\n *\n * const down = await visitorContentRefusal({ hostId })\n * if (down) return down\n *\n * The gap this closes, found by the AGL-1621 drill. The tenant middleware\n * is what takes a locked site off the air, and its matcher deliberately\n * excludes `/api` — so a full org or host takedown 503s every page while an\n * API route that composes and returns screen nodes keeps handing the site's\n * content out to anyone who can name a screen. `visitorWriteRefusal` above\n * cannot answer that: it leaves before any read, by design, because it is a\n * WRITE gate on the hot unauthenticated GET path.\n *\n * FULL locks only, and that is the whole point of it being a separate\n * helper rather than a flag on the write gate. Read-only mode exists so a\n * customer's site keeps serving and earning; refusing reads there would\n * spend exactly what the mode protects. `lockdownBlocks(state, 'read')` is\n * true only when the mode is full, which is precisely the case where the\n * pages are already gone.\n *\n * Deliberately NOT applied to the whole tenant API surface. A read-refusing\n * gate on every route would take the lock notice, the verdict probe and the\n * abuse intake down with the site. This is for the routes that serve the\n * SITE ITSELF — a composed node tree — where continuing to serve is\n * continuing to publish. `serve-media-cdn.ts` made the same call for bytes\n * (AGL-1520); this is the node-tree half of it.\n *\n * Fails OPEN through `getSiteLockdown`, like every other reader here.\n */\nexport async function visitorContentRefusal(options: {\n hostId: string\n surface?: LockdownPausedSurface\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const state = await getSiteLockdown(options.hostId, nowMs)\n if (!lockdownBlocks(state, 'read')) return null\n return lockdownJsonResponse(\n state as LockdownState,\n options.surface ? { notice: lockdownPausedNotice(options.surface) } : undefined,\n )\n}\n"],"names":["isLockdownActive","lockdownBlocks","lockdownIntentForMethod","lockdownPausedNotice","normalizeHostLockdown","normalizeOrgLockdown","resolveLockdown","getPlatformLockdown","heldSiteTakedown","lockdownJsonResponse","rememberSiteTakedown","getHostDocAdmin","getOrgForHost","getSiteLockdown","hostId","nowMs","Date","now","platform","resolved","host","Promise","all","org","hostState","state","error","console","held","catch","visitorWriteRefusal","options","intent","request","method","notice","surface","visitorContentRefusal","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GAED,SACEA,gBAAgB,EAChBC,cAAc,EAEdC,uBAAuB,EAEvBC,oBAAoB,EAEpBC,qBAAqB,EACrBC,oBAAoB,EACpBC,eAAe,QACV,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,gBAAgB,EAChBC,oBAAoB,EACpBC,oBAAoB,QACf,gBAAY;AACnB,SAASC,eAAe,EAAEC,aAAa,QAAQ,qBAAiB;AAEhE;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BC,GACD,OAAO,eAAeC,gBACpBC,MAAc,EACdC,QAAQC,KAAKC,GAAG,EAAE;IAElB,IAAI,CAACH,QAAQ,OAAO;IACpB,IAAI;QACF,MAAM,CAACI,UAAUC,UAAUC,KAAK,GAAG,MAAMC,QAAQC,GAAG,CAAC;YACnDf;YACAK,cAAcE;YACdH,gBAAgBG;SACjB;QACD,MAAMS,MAAMlB,qBAAqBc,4BAAAA,SAAUI,GAAG;QAC9C,MAAMC,YAAYpB,sBAAsBgB;QACxCV,qBAAqBI,QAAQ;YAAES;YAAKH,MAAMI;QAAU;QACpD,MAAMC,QAAQnB,gBAAgB;YAAEY;YAAUK;YAAKH,MAAMI;QAAU,GAAGT;QAClE,OAAOf,iBAAiByB,OAAOV,SAASU,QAAQ;IAClD,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,0CAA0CZ,QAAQY;QAChE,MAAME,OAAOpB,iBAAiBM,QAAQC;QACtC,yEAAyE;QACzE,sEAAsE;QACtE,IAAI,CAACa,KAAKL,GAAG,IAAI,CAACK,KAAKR,IAAI,EAAE,OAAO;QACpC,0EAA0E;QAC1E,wEAAwE;QACxE,uDAAuD;QACvD,MAAMF,WAAW,MAAMX,sBAAsBsB,KAAK,CAAC,IAAM;QACzD,MAAMJ,QAAQnB,gBACZ;YAAEY;YAAUK,KAAKK,KAAKL,GAAG;YAAEH,MAAMQ,KAAKR,IAAI;QAAC,GAC3CL;QAEF,OAAOf,iBAAiByB,OAAOV,SAASU,QAAQ;IAClD;AACF;AAEA;;;;;;;;CAQC,GACD,OAAO,eAAeK,oBAAoBC,OAQzC;QACeA,gBAEZA;IAFF,MAAMhB,SAAQgB,iBAAAA,QAAQhB,KAAK,YAAbgB,iBAAiBf,KAAKC,GAAG;IACvC,MAAMe,UACJD,kBAAAA,QAAQC,MAAM,YAAdD,kBACCA,QAAQE,OAAO,GAAG/B,wBAAwB6B,QAAQE,OAAO,CAACC,MAAM,IAAI;IACvE,0EAA0E;IAC1E,yEAAyE;IACzE,sEAAsE;IACtE,wEAAwE;IACxE,6DAA6D;IAC7D,IAAIF,WAAW,QAAQ,OAAO;IAC9B,MAAMP,QAAQ,MAAMZ,gBAAgBkB,QAAQjB,MAAM,EAAEC;IACpD,IAAI,CAACd,eAAewB,OAAOO,SAAS,OAAO;IAC3C,OAAOvB,qBAAqBgB,OAAwB;QAClDU,QAAQhC,qBAAqB4B,QAAQK,OAAO;IAC9C;AACF;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA8BC,GACD,OAAO,eAAeC,sBAAsBN,OAI3C;QACeA;IAAd,MAAMhB,SAAQgB,iBAAAA,QAAQhB,KAAK,YAAbgB,iBAAiBf,KAAKC,GAAG;IACvC,MAAMQ,QAAQ,MAAMZ,gBAAgBkB,QAAQjB,MAAM,EAAEC;IACpD,IAAI,CAACd,eAAewB,OAAO,SAAS,OAAO;IAC3C,OAAOhB,qBACLgB,OACAM,QAAQK,OAAO,GAAG;QAAED,QAAQhC,qBAAqB4B,QAAQK,OAAO;IAAE,IAAIE;AAE1E"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/tenant-write-lockdown.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 * The lockdown gate for VISITOR writes on a customer's live site\n * (AGL-1511) — form submissions, cart changes, checkout.\n *\n * Why this is its own chokepoint rather than the console's. The tenant\n * middleware, which is where AGL-1501 enforces, **does not match `/api`**\n * (see the matcher in apps/tenant/middleware.ts): it exists to stop CACHED\n * PAGES serving. So the site's own write endpoints have had no lockdown\n * enforcement at all — under a full org takedown the pages 503 while a form\n * POST still writes. This closes that for both modes.\n *\n * The product decision this file encodes, from the issue: a visitor-facing\n * write on a read-serving site gets a **polite inline pause, never a\n * page-level 503**. A customer's site staying up and earning is the entire\n * reason read-only mode exists instead of full lockdown, and answering their\n * visitors with an outage page would spend exactly what the mode was\n * protecting. So the refusal is a 423 with visitor copy the block renders\n * inline, and the page around it never stops rendering.\n *\n * Under a FULL lock the same 423 is returned rather than a 503, and that is\n * deliberate too: the middleware has already replaced the whole page for\n * anyone loading it, so a write arriving here is a stale tab or a script,\n * and a machine-readable refusal is more useful to both than an HTML page.\n *\n * NO staff bypass and NO user scope, matching /api/lockdown-verdict: this is\n * a public website, the caller is anonymous, and a visitor's identity plays\n * no part in whether a site accepts an order.\n */\n\nimport {\n isLockdownActive,\n lockdownBlocks,\n type LockdownIntent,\n lockdownIntentForMethod,\n type LockdownPausedSurface,\n lockdownPausedNotice,\n type LockdownState,\n normalizeHostLockdown,\n normalizeOrgLockdown,\n type PluginJobHostGate,\n resolveLockdown,\n} from '@aglyn/aglyn/server'\nimport {\n getPlatformLockdown,\n heldSiteTakedown,\n lockdownJsonResponse,\n rememberSiteTakedown,\n} from './lockdown'\nimport { getHostDocAdmin, getOrgForHost } from './organizations'\n\n/**\n * The active platform/org/host lockdown for one site, or null.\n *\n * Both reads are already paid by the callers on this path — the dispatcher\n * reads the host doc for its plugin deny-list and the org doc for\n * enablement, and both getters are `React.cache`-deduped per request — so\n * the gate costs one TTL-cached platform read and nothing else.\n *\n * FAIL OPEN on any error, matching every other lockdown reader: an\n * unreachable Firestore is an outage, not a lockdown, and a shop that stops\n * taking orders because a read timed out is a worse failure than the one\n * being guarded against.\n *\n * …EXCEPT for a takedown (AGL-1881, extending AGL-1621). One class of lock\n * inverts that cost function — \"the database was down\" is not an answer to a\n * court order — and until now the ledger that carries it covered only the\n * four scopes whose lock lives in `lockdowns/*`. Org and host locks live on\n * the org/host document, so the two scopes MOST likely to be carrying a\n * legal order were the two that fell through. Every successful read now\n * records its org and host verdict, and a failed read serves back whichever\n * of them was a takedown and is still active.\n *\n * Note what is NOT done here: the catch does not fail closed on the state it\n * could not read. On a read failure `suspendedEnforcement` is exactly as\n * unreadable as everything else, which is the whole reason a separate\n * durable ledger exists — and a catch that locked unconditionally would take\n * every shop on the platform offline on any transient blip, which is the\n * deliberate posture this keeps for the default case.\n */\nexport async function getSiteLockdown(\n hostId: string,\n nowMs = Date.now(),\n /**\n * The org and host documents, when the caller has already read them — a\n * sender resolving its identity holds both, and outside a request scope\n * `React.cache` dedupes nothing, so reading them again here would double\n * the reads on every message. `undefined` for either one means \"not\n * loaded, read it\"; `null` means \"read, and there is none\".\n */\n loaded?: {\n org?: Record<string, unknown> | null\n host?: Record<string, unknown> | null\n },\n): Promise<LockdownState | null> {\n if (!hostId) return null\n try {\n const [platform, resolved, host] = await Promise.all([\n getPlatformLockdown(),\n loaded?.org !== undefined\n ? Promise.resolve(loaded.org ? { org: loaded.org } : null)\n : getOrgForHost(hostId),\n loaded?.host !== undefined\n ? Promise.resolve(loaded.host)\n : getHostDocAdmin(hostId),\n ])\n const org = normalizeOrgLockdown(resolved?.org as never)\n const hostState = normalizeHostLockdown(host as never)\n rememberSiteTakedown(hostId, { org, host: hostState })\n const state = resolveLockdown({ platform, org, host: hostState }, nowMs)\n return isLockdownActive(state, nowMs) ? state : null\n } catch (error) {\n console.error('[tenant-write-lockdown] verdict failed', hostId, error)\n const held = heldSiteTakedown(hostId, nowMs)\n // The shipped fail-open answer, returned by the same statement it always\n // was, for every case where nothing takedown-class was ever observed.\n if (!held.org && !held.host) return null\n // The platform read is TTL-cached and swallows its own errors (it carries\n // its own ledger entry), so asking again here costs nothing and keeps a\n // wider lock from being masked by a narrower held one.\n const platform = await getPlatformLockdown().catch(() => null)\n const state = resolveLockdown(\n { platform, org: held.org, host: held.host },\n nowMs,\n )\n return isLockdownActive(state, nowMs) ? state : null\n }\n}\n\n/**\n * The background-work gate over {@link getSiteLockdown}: ANY active lock on\n * the site answers locked (AGL-2495, AGL-3356).\n *\n * One definition, two doors. The tenant's job runner registers\n * `isLocked` as core's job resolver (`apps/tenant/utils/plugin-job-lockdown`),\n * and the `x-cron-secret` doors that drive the same scans by hand in the\n * CONSOLE — abandoned carts, restock alerts, booking reminders — are handed\n * it directly. Those doors used to call core's `pluginJobHostGate()`, which\n * answers \"not locked\" in any process that never registered a resolver, and\n * the console registers none: a forced pass mailed a suspended site's\n * customers and stamped the rows done. Asking this instead needs no\n * registration, so it holds in whichever app serves the route.\n *\n * Any active lock, not only a full one, for the reason the tenant resolver\n * gives: every scan behind it mutates, and a skipped row is picked up\n * unchanged on the first pass after the lift.\n */\nexport function siteLockdownJobGate(): PluginJobHostGate {\n return {\n isLocked: async (hostId: string) =>\n Boolean(hostId) && (await getSiteLockdown(hostId)) !== null,\n }\n}\n\n/**\n * The refusal a visitor-facing write route returns, or null to proceed:\n *\n * const paused = await visitorWriteRefusal({ hostId, request, surface })\n * if (paused) return paused\n *\n * `surface` picks the WORDS only (`lockdownPausedNotice`) — the checkout\n * sentence has to promise no card was charged, and no generic copy can.\n */\nexport async function visitorWriteRefusal(options: {\n hostId: string\n /** The request whose method decides read vs write. */\n request?: { method?: string } | null\n /** Overrides `request`; `write` when neither is given. */\n intent?: LockdownIntent\n surface: LockdownPausedSurface\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const intent =\n options.intent ??\n (options.request ? lockdownIntentForMethod(options.request.method) : 'write')\n // This is a WRITE gate: reads leave before any Firestore read, so the hot\n // unauthenticated GET path pays nothing. A full lock's refusal of tenant\n // API *reads* is not attempted here and is unchanged from today — the\n // middleware owns the page surface, and a read-refusing API gate is its\n // own decision with its own blast radius (filed separately).\n if (intent === 'read') return null\n const state = await getSiteLockdown(options.hostId, nowMs)\n if (!lockdownBlocks(state, intent)) return null\n return lockdownJsonResponse(state as LockdownState, {\n notice: lockdownPausedNotice(options.surface),\n })\n}\n\n/**\n * The refusal a visitor-facing route that SERVES SITE CONTENT over `/api`\n * returns under a FULL lock, or null to proceed (AGL-2495):\n *\n * const down = await visitorContentRefusal({ hostId })\n * if (down) return down\n *\n * The gap this closes, found by the AGL-1621 drill. The tenant middleware\n * is what takes a locked site off the air, and its matcher deliberately\n * excludes `/api` — so a full org or host takedown 503s every page while an\n * API route that composes and returns screen nodes keeps handing the site's\n * content out to anyone who can name a screen. `visitorWriteRefusal` above\n * cannot answer that: it leaves before any read, by design, because it is a\n * WRITE gate on the hot unauthenticated GET path.\n *\n * FULL locks only, and that is the whole point of it being a separate\n * helper rather than a flag on the write gate. Read-only mode exists so a\n * customer's site keeps serving and earning; refusing reads there would\n * spend exactly what the mode protects. `lockdownBlocks(state, 'read')` is\n * true only when the mode is full, which is precisely the case where the\n * pages are already gone.\n *\n * Deliberately NOT applied to the whole tenant API surface. A read-refusing\n * gate on every route would take the lock notice, the verdict probe and the\n * abuse intake down with the site. This is for the routes that serve the\n * SITE ITSELF — a composed node tree — where continuing to serve is\n * continuing to publish. `serve-media-cdn.ts` made the same call for bytes\n * (AGL-1520); this is the node-tree half of it.\n *\n * Fails OPEN through `getSiteLockdown`, like every other reader here.\n */\nexport async function visitorContentRefusal(options: {\n hostId: string\n surface?: LockdownPausedSurface\n nowMs?: number\n}): Promise<Response | null> {\n const nowMs = options.nowMs ?? Date.now()\n const state = await getSiteLockdown(options.hostId, nowMs)\n if (!lockdownBlocks(state, 'read')) return null\n return lockdownJsonResponse(\n state as LockdownState,\n options.surface ? { notice: lockdownPausedNotice(options.surface) } : undefined,\n )\n}\n"],"names":["isLockdownActive","lockdownBlocks","lockdownIntentForMethod","lockdownPausedNotice","normalizeHostLockdown","normalizeOrgLockdown","resolveLockdown","getPlatformLockdown","heldSiteTakedown","lockdownJsonResponse","rememberSiteTakedown","getHostDocAdmin","getOrgForHost","getSiteLockdown","hostId","nowMs","Date","now","loaded","platform","resolved","host","Promise","all","org","undefined","resolve","hostState","state","error","console","held","catch","siteLockdownJobGate","isLocked","Boolean","visitorWriteRefusal","options","intent","request","method","notice","surface","visitorContentRefusal"],"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":"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"}
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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"}
package/src/tenancy.d.ts CHANGED
@@ -53,6 +53,7 @@ export * from './lib/server/edit-hint-token';
53
53
  export * from './lib/server/email-metering';
54
54
  export * from './lib/server/campaign-conversion-attribution';
55
55
  export * from './lib/server/email-send-rate';
56
+ export * from './lib/server/email-deliverability';
56
57
  export * from './lib/server/email-sender-reputation';
57
58
  export * from './lib/server/erase';
58
59
  export * from './lib/server/erase-person';
@@ -66,6 +67,8 @@ export * from './lib/server/list-members';
66
67
  export * from './lib/server/lockdown';
67
68
  export * from './lib/server/token-revocation';
68
69
  export * from './lib/server/tenant-write-lockdown';
70
+ export * from './lib/server/outbound-send-review';
71
+ export * from './lib/server/hosted-page-review';
69
72
  export * from './lib/server/host-email-tokens';
70
73
  export * from './lib/server/notifications';
71
74
  export * from './lib/server/org-billing';
package/src/tenancy.js CHANGED
@@ -67,6 +67,11 @@ export * from "./lib/server/email-metering.js";
67
67
  // imports this barrel, so nothing has to remember to call an installer.
68
68
  export * from "./lib/server/campaign-conversion-attribution.js";
69
69
  export * from "./lib/server/email-send-rate.js";
70
+ // Side-effecting on purpose, for the same reason (AGL-3328): evaluating this
71
+ // module INSTALLS the deliverability preflight on `sendEmail`, so a recipient
72
+ // whose domain takes no mail is refused, and bulk mail behind a gateway that
73
+ // refused its sending domain twice is held, on every send path at once.
74
+ export * from "./lib/server/email-deliverability.js";
70
75
  export * from "./lib/server/email-sender-reputation.js";
71
76
  export * from "./lib/server/erase.js";
72
77
  export * from "./lib/server/erase-person.js";
@@ -80,6 +85,9 @@ export * from "./lib/server/list-members.js";
80
85
  export * from "./lib/server/lockdown.js";
81
86
  export * from "./lib/server/token-revocation.js";
82
87
  export * from "./lib/server/tenant-write-lockdown.js";
88
+ export * from "./lib/server/outbound-send-review.js";
89
+ // The same review for a published page, read where it is composed (AGL-3356).
90
+ export * from "./lib/server/hosted-page-review.js";
83
91
  export * from "./lib/server/host-email-tokens.js";
84
92
  export * from "./lib/server/notifications.js";
85
93
  export * from "./lib/server/org-billing.js";
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/data/admin/src/tenancy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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\nexport * from './lib/server/analytics-retention'\nexport * from './lib/server/api-http'\nexport * from './lib/server/api-keys'\nexport * from './lib/server/account-emails'\nexport * from './lib/server/auth-pools'\nexport * from './lib/server/client-error-report'\nexport * from './lib/server/connect-account-status'\nexport * from './lib/server/connect-payout-failure'\nexport * from './lib/server/stripe-account-mode'\nexport * from './lib/server/auth-handoff'\nexport * from './lib/server/console-domains'\nexport * from './lib/server/dns-probe'\nexport * from './lib/server/sending-domains'\nexport * from './lib/server/host-sending-domain'\nexport * from './lib/server/sending-domain-debt'\nexport * from './lib/server/sending-domain-recheck'\n// The hosting seam itself, so a door can ask whether this deployment is able\n// to register a name at all before it promises the caller one.\n// `workspace-domains` re-exports the pieces its own callers share with it; the\n// provider behind it is not among them.\nexport { domainProvider } from './lib/server/domain-provider'\nexport type {\n DomainProvider,\n DomainProviderId,\n DomainScope,\n} from './lib/server/domain-provider'\nexport * from './lib/server/platform-domain-names'\nexport * from './lib/server/consume-once'\nexport * from './lib/server/csp-aggregate'\nexport * from './lib/server/data-storage-gate'\nexport * from './lib/server/contact-suppression'\nexport * from './lib/server/email-suppression'\n// The double opt-in quarantine, after the module whose keying it shares and\n// whose `filterTopicSendable` is the reader that makes it a quarantine rather\n// than a stored fact.\nexport * from './lib/server/email-topic-confirmation'\nexport * from './lib/server/email-unsubscribe-link'\nexport * from './lib/server/email-flow-gate'\n// Side-effecting on purpose, for the reason `email-send-rate` below is:\n// evaluating this module INSTALLS the marketing gate on `sendEmail`, so the\n// unsubscribe header, the suppression check and the frequency ceiling reach\n// every bulk sender without a call at each server entrypoint.\nexport * from './lib/server/email-marketing-gate'\n// A consent group change (AGL-3320): the carries of the three stores above,\n// and the executor that runs a change around them.\nexport * from './lib/server/consent-group-carry'\nexport * from './lib/server/consent-group-change'\nexport * from './lib/server/sms-keywords'\nexport * from './lib/server/document-id'\nexport * from './lib/server/collection-preview-token'\nexport * from './lib/server/edit-access-authz'\nexport * from './lib/server/edit-access-token'\nexport * from './lib/server/edit-hint-token'\nexport * from './lib/server/email-metering'\n// Side-effecting on purpose (AGL-2409): evaluating this module INSTALLS the\n// platform send-rate governor on `sendEmail`. Every server surface already\n// imports this barrel, so nothing has to remember to call an installer.\nexport * from './lib/server/campaign-conversion-attribution'\nexport * from './lib/server/email-send-rate'\nexport * from './lib/server/email-sender-reputation'\nexport * from './lib/server/erase'\nexport * from './lib/server/erase-person'\nexport * from './lib/server/dynamic-list-materialize'\nexport * from './lib/server/firebase-admin'\nexport * from './lib/server/free-workspace-cap'\nexport * from './lib/server/ga4-measurement-protocol'\nexport * from './lib/server/host-memberships'\nexport * from './lib/server/host-visitor-records'\nexport * from './lib/server/list-members'\nexport * from './lib/server/lockdown'\nexport * from './lib/server/token-revocation'\nexport * from './lib/server/tenant-write-lockdown'\nexport * from './lib/server/host-email-tokens'\nexport * from './lib/server/notifications'\nexport * from './lib/server/org-billing'\nexport * from './lib/server/organizations'\nexport * from './lib/server/duplicate-activity'\nexport * from './lib/server/duplicate-resource'\nexport * from './lib/server/workspace-domains'\nexport * from './lib/server/membership-recover-throttle'\nexport * from './lib/server/password-reset-throttle'\nexport * from './lib/server/rate-limit-store'\nexport * from './lib/server/verify-email-cooldown'\nexport * from './lib/server/vercel-drain-signature'\nexport * from './lib/server/vercel-log-drain'\nexport * from './lib/server/visitor-write-rate-limit'\nexport * from './lib/server/console-api-rate-limit'\nexport * from './lib/server/realm-plugins'\nexport * from './lib/server/release-flags'\nexport * from './lib/server/resolve-people'\nexport * from './lib/server/member-photo'\nexport * from './lib/server/personal-data-export'\nexport * from './lib/server/media-delivery'\nexport * from './lib/server/media-download-tokens'\nexport * from './lib/server/media-quarantine'\nexport * from './lib/server/media-signing'\nexport * from './lib/server/media-storage-path'\nexport * from './lib/server/safe-equal'\nexport * from './lib/server/media-strong-digest'\nexport * from './lib/server/media-tombstone'\nexport * from './lib/server/media-variants'\nexport * from './lib/server/serve-media-cdn'\nexport * from './lib/server/serve-plugin-fetch'\nexport * from './lib/server/sso-break-glass-owners'\nexport * from './lib/server/sso-domain-policy'\nexport * from './lib/server/sso-enforcement'\nexport * from './lib/server/sso-provisioning'\nexport * from './lib/server/update-existing'\nexport * from './lib/server/contact-company-link'\nexport * from './lib/server/contact-email-index'\nexport * from './lib/server/contact-merge'\nexport * from './lib/server/upsert-contact'\n// The stage floor a won deal applies to its contact (AGL-2641).\nexport * from './lib/server/contact-lifecycle-floor'\nexport * from './lib/server/crm-records'\n// The `nextTaskAtMs` a contact, a company and a deal carry, recomputed from\n// their open tasks by every server-side task writer (AGL-2661).\nexport * from './lib/server/crm-next-activity'\n// Where a one-to-one email's activity row lives and how the delivery\n// webhook advances it (AGL-2615).\nexport * from './lib/server/crm-email-activity'\n// What a confirmed booking files on the CRM record — the meeting and the\n// follow-up (AGL-2660).\nexport * from './lib/server/crm-booking-activity'\n// The org's email capture token and the filing of one received message\n// on the record it was with (AGL-2657).\nexport * from './lib/server/crm-inbound-email'\n// A member's own addresses in a workspace, and the signed link that\n// confirms one (AGL-2975).\nexport * from './lib/server/member-email-aliases'\nexport * from './lib/server/user-profiles'\nexport * from './lib/server/legal-acceptance'\n// The person's decision about the platform's own product email, on their\n// document and on the operator's marketing contact (AGL-3185).\nexport * from './lib/server/platform-marketing-consent'\n"],"names":["domainProvider"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,cAAc,sCAAkC;AAChD,cAAc,2BAAuB;AACrC,cAAc,2BAAuB;AACrC,cAAc,iCAA6B;AAC3C,cAAc,6BAAyB;AACvC,cAAc,sCAAkC;AAChD,cAAc,yCAAqC;AACnD,cAAc,yCAAqC;AACnD,cAAc,sCAAkC;AAChD,cAAc,+BAA2B;AACzC,cAAc,kCAA8B;AAC5C,cAAc,4BAAwB;AACtC,cAAc,kCAA8B;AAC5C,cAAc,sCAAkC;AAChD,cAAc,sCAAkC;AAChD,cAAc,yCAAqC;AACnD,6EAA6E;AAC7E,+DAA+D;AAC/D,+EAA+E;AAC/E,wCAAwC;AACxC,SAASA,cAAc,QAAQ,kCAA8B;AAM7D,cAAc,wCAAoC;AAClD,cAAc,+BAA2B;AACzC,cAAc,gCAA4B;AAC1C,cAAc,oCAAgC;AAC9C,cAAc,sCAAkC;AAChD,cAAc,oCAAgC;AAC9C,4EAA4E;AAC5E,8EAA8E;AAC9E,sBAAsB;AACtB,cAAc,2CAAuC;AACrD,cAAc,yCAAqC;AACnD,cAAc,kCAA8B;AAC5C,wEAAwE;AACxE,4EAA4E;AAC5E,4EAA4E;AAC5E,8DAA8D;AAC9D,cAAc,uCAAmC;AACjD,4EAA4E;AAC5E,mDAAmD;AACnD,cAAc,sCAAkC;AAChD,cAAc,uCAAmC;AACjD,cAAc,+BAA2B;AACzC,cAAc,8BAA0B;AACxC,cAAc,2CAAuC;AACrD,cAAc,oCAAgC;AAC9C,cAAc,oCAAgC;AAC9C,cAAc,kCAA8B;AAC5C,cAAc,iCAA6B;AAC3C,4EAA4E;AAC5E,2EAA2E;AAC3E,wEAAwE;AACxE,cAAc,kDAA8C;AAC5D,cAAc,kCAA8B;AAC5C,cAAc,0CAAsC;AACpD,cAAc,wBAAoB;AAClC,cAAc,+BAA2B;AACzC,cAAc,2CAAuC;AACrD,cAAc,iCAA6B;AAC3C,cAAc,qCAAiC;AAC/C,cAAc,2CAAuC;AACrD,cAAc,mCAA+B;AAC7C,cAAc,uCAAmC;AACjD,cAAc,+BAA2B;AACzC,cAAc,2BAAuB;AACrC,cAAc,mCAA+B;AAC7C,cAAc,wCAAoC;AAClD,cAAc,oCAAgC;AAC9C,cAAc,gCAA4B;AAC1C,cAAc,8BAA0B;AACxC,cAAc,gCAA4B;AAC1C,cAAc,qCAAiC;AAC/C,cAAc,qCAAiC;AAC/C,cAAc,oCAAgC;AAC9C,cAAc,8CAA0C;AACxD,cAAc,0CAAsC;AACpD,cAAc,mCAA+B;AAC7C,cAAc,wCAAoC;AAClD,cAAc,yCAAqC;AACnD,cAAc,mCAA+B;AAC7C,cAAc,2CAAuC;AACrD,cAAc,yCAAqC;AACnD,cAAc,gCAA4B;AAC1C,cAAc,gCAA4B;AAC1C,cAAc,iCAA6B;AAC3C,cAAc,+BAA2B;AACzC,cAAc,uCAAmC;AACjD,cAAc,iCAA6B;AAC3C,cAAc,wCAAoC;AAClD,cAAc,mCAA+B;AAC7C,cAAc,gCAA4B;AAC1C,cAAc,qCAAiC;AAC/C,cAAc,6BAAyB;AACvC,cAAc,sCAAkC;AAChD,cAAc,kCAA8B;AAC5C,cAAc,iCAA6B;AAC3C,cAAc,kCAA8B;AAC5C,cAAc,qCAAiC;AAC/C,cAAc,yCAAqC;AACnD,cAAc,oCAAgC;AAC9C,cAAc,kCAA8B;AAC5C,cAAc,mCAA+B;AAC7C,cAAc,kCAA8B;AAC5C,cAAc,uCAAmC;AACjD,cAAc,sCAAkC;AAChD,cAAc,gCAA4B;AAC1C,cAAc,iCAA6B;AAC3C,gEAAgE;AAChE,cAAc,0CAAsC;AACpD,cAAc,8BAA0B;AACxC,4EAA4E;AAC5E,gEAAgE;AAChE,cAAc,oCAAgC;AAC9C,qEAAqE;AACrE,kCAAkC;AAClC,cAAc,qCAAiC;AAC/C,yEAAyE;AACzE,wBAAwB;AACxB,cAAc,uCAAmC;AACjD,uEAAuE;AACvE,wCAAwC;AACxC,cAAc,oCAAgC;AAC9C,oEAAoE;AACpE,2BAA2B;AAC3B,cAAc,uCAAmC;AACjD,cAAc,gCAA4B;AAC1C,cAAc,mCAA+B;AAC7C,yEAAyE;AACzE,+DAA+D;AAC/D,cAAc,6CAAyC"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/data/admin/src/tenancy.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2022 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\nexport * from './lib/server/analytics-retention'\nexport * from './lib/server/api-http'\nexport * from './lib/server/api-keys'\nexport * from './lib/server/account-emails'\nexport * from './lib/server/auth-pools'\nexport * from './lib/server/client-error-report'\nexport * from './lib/server/connect-account-status'\nexport * from './lib/server/connect-payout-failure'\nexport * from './lib/server/stripe-account-mode'\nexport * from './lib/server/auth-handoff'\nexport * from './lib/server/console-domains'\nexport * from './lib/server/dns-probe'\nexport * from './lib/server/sending-domains'\nexport * from './lib/server/host-sending-domain'\nexport * from './lib/server/sending-domain-debt'\nexport * from './lib/server/sending-domain-recheck'\n// The hosting seam itself, so a door can ask whether this deployment is able\n// to register a name at all before it promises the caller one.\n// `workspace-domains` re-exports the pieces its own callers share with it; the\n// provider behind it is not among them.\nexport { domainProvider } from './lib/server/domain-provider'\nexport type {\n DomainProvider,\n DomainProviderId,\n DomainScope,\n} from './lib/server/domain-provider'\nexport * from './lib/server/platform-domain-names'\nexport * from './lib/server/consume-once'\nexport * from './lib/server/csp-aggregate'\nexport * from './lib/server/data-storage-gate'\nexport * from './lib/server/contact-suppression'\nexport * from './lib/server/email-suppression'\n// The double opt-in quarantine, after the module whose keying it shares and\n// whose `filterTopicSendable` is the reader that makes it a quarantine rather\n// than a stored fact.\nexport * from './lib/server/email-topic-confirmation'\nexport * from './lib/server/email-unsubscribe-link'\nexport * from './lib/server/email-flow-gate'\n// Side-effecting on purpose, for the reason `email-send-rate` below is:\n// evaluating this module INSTALLS the marketing gate on `sendEmail`, so the\n// unsubscribe header, the suppression check and the frequency ceiling reach\n// every bulk sender without a call at each server entrypoint.\nexport * from './lib/server/email-marketing-gate'\n// A consent group change (AGL-3320): the carries of the three stores above,\n// and the executor that runs a change around them.\nexport * from './lib/server/consent-group-carry'\nexport * from './lib/server/consent-group-change'\nexport * from './lib/server/sms-keywords'\nexport * from './lib/server/document-id'\nexport * from './lib/server/collection-preview-token'\nexport * from './lib/server/edit-access-authz'\nexport * from './lib/server/edit-access-token'\nexport * from './lib/server/edit-hint-token'\nexport * from './lib/server/email-metering'\n// Side-effecting on purpose (AGL-2409): evaluating this module INSTALLS the\n// platform send-rate governor on `sendEmail`. Every server surface already\n// imports this barrel, so nothing has to remember to call an installer.\nexport * from './lib/server/campaign-conversion-attribution'\nexport * from './lib/server/email-send-rate'\n// Side-effecting on purpose, for the same reason (AGL-3328): evaluating this\n// module INSTALLS the deliverability preflight on `sendEmail`, so a recipient\n// whose domain takes no mail is refused, and bulk mail behind a gateway that\n// refused its sending domain twice is held, on every send path at once.\nexport * from './lib/server/email-deliverability'\nexport * from './lib/server/email-sender-reputation'\nexport * from './lib/server/erase'\nexport * from './lib/server/erase-person'\nexport * from './lib/server/dynamic-list-materialize'\nexport * from './lib/server/firebase-admin'\nexport * from './lib/server/free-workspace-cap'\nexport * from './lib/server/ga4-measurement-protocol'\nexport * from './lib/server/host-memberships'\nexport * from './lib/server/host-visitor-records'\nexport * from './lib/server/list-members'\nexport * from './lib/server/lockdown'\nexport * from './lib/server/token-revocation'\nexport * from './lib/server/tenant-write-lockdown'\nexport * from './lib/server/outbound-send-review'\n// The same review for a published page, read where it is composed (AGL-3356).\nexport * from './lib/server/hosted-page-review'\nexport * from './lib/server/host-email-tokens'\nexport * from './lib/server/notifications'\nexport * from './lib/server/org-billing'\nexport * from './lib/server/organizations'\nexport * from './lib/server/duplicate-activity'\nexport * from './lib/server/duplicate-resource'\nexport * from './lib/server/workspace-domains'\nexport * from './lib/server/membership-recover-throttle'\nexport * from './lib/server/password-reset-throttle'\nexport * from './lib/server/rate-limit-store'\nexport * from './lib/server/verify-email-cooldown'\nexport * from './lib/server/vercel-drain-signature'\nexport * from './lib/server/vercel-log-drain'\nexport * from './lib/server/visitor-write-rate-limit'\nexport * from './lib/server/console-api-rate-limit'\nexport * from './lib/server/realm-plugins'\nexport * from './lib/server/release-flags'\nexport * from './lib/server/resolve-people'\nexport * from './lib/server/member-photo'\nexport * from './lib/server/personal-data-export'\nexport * from './lib/server/media-delivery'\nexport * from './lib/server/media-download-tokens'\nexport * from './lib/server/media-quarantine'\nexport * from './lib/server/media-signing'\nexport * from './lib/server/media-storage-path'\nexport * from './lib/server/safe-equal'\nexport * from './lib/server/media-strong-digest'\nexport * from './lib/server/media-tombstone'\nexport * from './lib/server/media-variants'\nexport * from './lib/server/serve-media-cdn'\nexport * from './lib/server/serve-plugin-fetch'\nexport * from './lib/server/sso-break-glass-owners'\nexport * from './lib/server/sso-domain-policy'\nexport * from './lib/server/sso-enforcement'\nexport * from './lib/server/sso-provisioning'\nexport * from './lib/server/update-existing'\nexport * from './lib/server/contact-company-link'\nexport * from './lib/server/contact-email-index'\nexport * from './lib/server/contact-merge'\nexport * from './lib/server/upsert-contact'\n// The stage floor a won deal applies to its contact (AGL-2641).\nexport * from './lib/server/contact-lifecycle-floor'\nexport * from './lib/server/crm-records'\n// The `nextTaskAtMs` a contact, a company and a deal carry, recomputed from\n// their open tasks by every server-side task writer (AGL-2661).\nexport * from './lib/server/crm-next-activity'\n// Where a one-to-one email's activity row lives and how the delivery\n// webhook advances it (AGL-2615).\nexport * from './lib/server/crm-email-activity'\n// What a confirmed booking files on the CRM record — the meeting and the\n// follow-up (AGL-2660).\nexport * from './lib/server/crm-booking-activity'\n// The org's email capture token and the filing of one received message\n// on the record it was with (AGL-2657).\nexport * from './lib/server/crm-inbound-email'\n// A member's own addresses in a workspace, and the signed link that\n// confirms one (AGL-2975).\nexport * from './lib/server/member-email-aliases'\nexport * from './lib/server/user-profiles'\nexport * from './lib/server/legal-acceptance'\n// The person's decision about the platform's own product email, on their\n// document and on the operator's marketing contact (AGL-3185).\nexport * from './lib/server/platform-marketing-consent'\n"],"names":["domainProvider"],"mappings":"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"}