@aglyn/tenant-data-admin 1.0.0-beta.219 → 1.0.0-beta.220

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 (85) hide show
  1. package/package.json +8 -8
  2. package/src/lib/server/api-http.js +5 -1
  3. package/src/lib/server/api-http.js.map +1 -1
  4. package/src/lib/server/api-v1-kit.d.ts +7 -0
  5. package/src/lib/server/api-v1-kit.js.map +1 -1
  6. package/src/lib/server/api-v1-resources.d.ts +1 -2
  7. package/src/lib/server/api-v1-resources.js +0 -1
  8. package/src/lib/server/api-v1-resources.js.map +1 -1
  9. package/src/lib/server/campaign-conversion-attribution.d.ts +1 -1
  10. package/src/lib/server/campaign-conversion-attribution.js +3 -3
  11. package/src/lib/server/campaign-conversion-attribution.js.map +1 -1
  12. package/src/lib/server/card-payment-velocity.d.ts +9 -0
  13. package/src/lib/server/card-payment-velocity.js +28 -2
  14. package/src/lib/server/card-payment-velocity.js.map +1 -1
  15. package/src/lib/server/consent-group-carry.js +1 -1
  16. package/src/lib/server/consent-group-carry.js.map +1 -1
  17. package/src/lib/server/consent-group-change.js +2 -2
  18. package/src/lib/server/consent-group-change.js.map +1 -1
  19. package/src/lib/server/dynamic-list-materialize.js +3 -3
  20. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  21. package/src/lib/server/email-flow-gate.js +2 -2
  22. package/src/lib/server/email-flow-gate.js.map +1 -1
  23. package/src/lib/server/email-suppression.js +1 -1
  24. package/src/lib/server/email-suppression.js.map +1 -1
  25. package/src/lib/server/host-visitor-records.d.ts +32 -4
  26. package/src/lib/server/host-visitor-records.js +73 -14
  27. package/src/lib/server/host-visitor-records.js.map +1 -1
  28. package/src/lib/server/hosted-page-review.d.ts +27 -0
  29. package/src/lib/server/hosted-page-review.js +149 -12
  30. package/src/lib/server/hosted-page-review.js.map +1 -1
  31. package/src/lib/server/media-cdn-rate-limit.d.ts +6 -3
  32. package/src/lib/server/media-cdn-rate-limit.js +6 -3
  33. package/src/lib/server/media-cdn-rate-limit.js.map +1 -1
  34. package/src/lib/server/member-email-aliases.d.ts +9 -1
  35. package/src/lib/server/member-email-aliases.js +25 -1
  36. package/src/lib/server/member-email-aliases.js.map +1 -1
  37. package/src/lib/server/notifications.d.ts +14 -0
  38. package/src/lib/server/notifications.js +29 -0
  39. package/src/lib/server/notifications.js.map +1 -1
  40. package/src/lib/server/operator-alerts.d.ts +7 -1
  41. package/src/lib/server/operator-alerts.js +5 -3
  42. package/src/lib/server/operator-alerts.js.map +1 -1
  43. package/src/lib/server/org-containers.d.ts +66 -0
  44. package/src/lib/server/org-containers.js +95 -0
  45. package/src/lib/server/org-containers.js.map +1 -0
  46. package/src/lib/server/org-member-notice.js +3 -2
  47. package/src/lib/server/org-member-notice.js.map +1 -1
  48. package/src/lib/server/payment-fraud-signal.js +5 -1
  49. package/src/lib/server/payment-fraud-signal.js.map +1 -1
  50. package/src/lib/server/payment-provider-stripe-connect-payouts.js +35 -1
  51. package/src/lib/server/payment-provider-stripe-connect-payouts.js.map +1 -1
  52. package/src/lib/server/payment-risk-record.d.ts +1 -1
  53. package/src/lib/server/payment-risk-record.js +3 -1
  54. package/src/lib/server/payment-risk-record.js.map +1 -1
  55. package/src/lib/server/platform-marketing-consent.d.ts +1 -1
  56. package/src/lib/server/platform-marketing-consent.js +1 -1
  57. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  58. package/src/lib/server/risk-notice.d.ts +32 -1
  59. package/src/lib/server/risk-notice.js +90 -28
  60. package/src/lib/server/risk-notice.js.map +1 -1
  61. package/src/lib/server/sending-domains.d.ts +17 -0
  62. package/src/lib/server/sending-domains.js +28 -4
  63. package/src/lib/server/sending-domains.js.map +1 -1
  64. package/src/lib/server/sso-enforcement.js +7 -2
  65. package/src/lib/server/sso-enforcement.js.map +1 -1
  66. package/src/lib/server/staff-alert-email.js +4 -2
  67. package/src/lib/server/staff-alert-email.js.map +1 -1
  68. package/src/lib/server/{email-topic-confirmation.js → topic-subscriptions.js} +15 -11
  69. package/src/lib/server/topic-subscriptions.js.map +1 -0
  70. package/src/lib/server/upsert-contact.js +2 -2
  71. package/src/lib/server/upsert-contact.js.map +1 -1
  72. package/src/tenancy.d.ts +1 -3
  73. package/src/tenancy.js +1 -7
  74. package/src/tenancy.js.map +1 -1
  75. package/src/lib/server/contact-email-engagement.d.ts +0 -32
  76. package/src/lib/server/contact-email-engagement.js +0 -145
  77. package/src/lib/server/contact-email-engagement.js.map +0 -1
  78. package/src/lib/server/crm-booking-activity.d.ts +0 -86
  79. package/src/lib/server/crm-booking-activity.js +0 -257
  80. package/src/lib/server/crm-booking-activity.js.map +0 -1
  81. package/src/lib/server/crm-inbound-email.d.ts +0 -168
  82. package/src/lib/server/crm-inbound-email.js +0 -282
  83. package/src/lib/server/crm-inbound-email.js.map +0 -1
  84. package/src/lib/server/email-topic-confirmation.js.map +0 -1
  85. /package/src/lib/server/{email-topic-confirmation.d.ts → topic-subscriptions.d.ts} +0 -0
@@ -15,11 +15,16 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ /**
18
- * DOUBLE OPT-IN — the durable half.
18
+ * A RECIPIENT'S STREAMS, WRITTEN — the durable half of the mail rail's
19
+ * per-stream consent: an opt-out, and the double opt-in's pending and
20
+ * confirmed marks.
19
21
  *
20
- * `email-topics.ts` in the framework states the policy and owns the three
21
- * states an entry can be in; this writes them, because writing them needs
22
- * Firestore.
22
+ * `subscription-topics.ts` in the framework states the policy and owns the
23
+ * three states an entry can be in; this writes them, because writing them
24
+ * needs Firestore. Every sender's stream is written the same way — a
25
+ * newsletter signup asking for a confirmation, a sequence recipient asking to
26
+ * hear no more sales mail — which is why it sits beside the suppression list
27
+ * rather than in any one plugin.
23
28
  *
24
29
  * ## The record it writes is the one the send path already reads
25
30
  *
@@ -40,8 +45,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
40
45
  * needs, which is the same argument `writeTopicOptOuts` makes for keeping an
41
46
  * opt-out entry after somebody rejoins.
42
47
  */ import { FieldValue } from "firebase-admin/firestore";
43
- import { doubleOptInExpired, readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/email-topics";
44
- import { isEmailTopicId } from "@aglyn/aglyn/app-utils/email-topics";
48
+ import { doubleOptInExpired, isSubscriptionTopicId, readTopicSubscriptionState, TOPIC_OPT_OUTS_SUBCOLLECTION } from "@aglyn/aglyn/app-utils/subscription-topics";
45
49
  import firebaseAdmin from "./firebase-admin.js";
46
50
  import { emailSuppressionKey, releaseEmailForConfirmedOptIn } from "./email-suppression.js";
47
51
  const defaultFirestore = ()=>firebaseAdmin.app().firestore();
@@ -70,7 +74,7 @@ function optOutDoc(hostId, key, firestore) {
70
74
  */ export async function recordPendingTopicConfirmation(hostId, email, topicId, options) {
71
75
  var _ref, _ref1;
72
76
  const key = emailSuppressionKey(email);
73
- if (!key || !hostId || !isEmailTopicId(topicId)) {
77
+ if (!key || !hostId || !isSubscriptionTopicId(topicId)) {
74
78
  return {
75
79
  result: 'unusable',
76
80
  pendingAtMs: null
@@ -166,7 +170,7 @@ function optOutDoc(hostId, key, firestore) {
166
170
  */ export async function confirmTopicSubscription(hostId, email, topicId, options) {
167
171
  var _ref, _ref1;
168
172
  const key = emailSuppressionKey(email);
169
- if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable';
173
+ if (!key || !hostId || !isSubscriptionTopicId(topicId)) return 'unusable';
170
174
  const nowMs = (_ref = options == null ? void 0 : options.nowMs) != null ? _ref : Date.now();
171
175
  const ref = optOutDoc(hostId, key, options == null ? void 0 : options.firestore);
172
176
  /*
@@ -230,7 +234,7 @@ function optOutDoc(hostId, key, firestore) {
230
234
  const snapshot = await (firestore != null ? firestore : defaultFirestore()).collection('hosts').doc(hostId).get();
231
235
  return snapshot.exists && snapshot.get('emailDoubleOptIn') === true;
232
236
  } catch (error) {
233
- console.error('[email-topics] double opt-in setting read failed', error);
237
+ console.error('[topic-subscriptions] double opt-in setting read failed', error);
234
238
  return false;
235
239
  }
236
240
  }
@@ -255,7 +259,7 @@ function optOutDoc(hostId, key, firestore) {
255
259
  */ export async function recordTopicOptOut(hostId, email, topicId, options) {
256
260
  var _ref;
257
261
  const key = emailSuppressionKey(email);
258
- if (!key || !hostId || !isEmailTopicId(topicId)) return 'unusable';
262
+ if (!key || !hostId || !isSubscriptionTopicId(topicId)) return 'unusable';
259
263
  const db = (_ref = options == null ? void 0 : options.firestore) != null ? _ref : defaultFirestore();
260
264
  const ref = optOutDoc(hostId, key, db);
261
265
  return db.runTransaction(async (transaction)=>{
@@ -282,4 +286,4 @@ function optOutDoc(hostId, key, firestore) {
282
286
  });
283
287
  }
284
288
 
285
- //# sourceMappingURL=email-topic-confirmation.js.map
289
+ //# sourceMappingURL=topic-subscriptions.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/topic-subscriptions.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 * A RECIPIENT'S STREAMS, WRITTEN — the durable half of the mail rail's\n * per-stream consent: an opt-out, and the double opt-in's pending and\n * confirmed marks.\n *\n * `subscription-topics.ts` in the framework states the policy and owns the\n * three states an entry can be in; this writes them, because writing them\n * needs Firestore. Every sender's stream is written the same way — a\n * newsletter signup asking for a confirmation, a sequence recipient asking to\n * hear no more sales mail — which is why it sits beside the suppression list\n * rather than in any one plugin.\n *\n * ## The record it writes is the one the send path already reads\n *\n * `hosts/{hostId}/topicOptOuts/{emailKey}` holds a map keyed by topic, and\n * `filterTopicSendable` reads it on every topic-filtered send. A pending\n * confirmation is therefore a THIRD state of a fact the send path already\n * consults rather than a new document nobody has a reason to look at — which\n * is the difference between ActiveCampaign's quarantine, where an unconfirmed\n * subscriber genuinely cannot be mailed, and Customer.io's recipe, whose own\n * documentation warns that it \"doesn't automatically check this attribute\n * before sending messages\".\n *\n * ## Nothing here is a deletion\n *\n * A confirmation stamps `confirmedAt` beside the `pendingAt` it answers,\n * rather than clearing the pending mark. The pair IS the evidence — when we\n * asked and when they answered — and it is the evidence a consent question\n * needs, which is the same argument `writeTopicOptOuts` makes for keeping an\n * opt-out entry after somebody rejoins.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n doubleOptInExpired,\n isSubscriptionTopicId,\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/subscription-topics'\nimport firebaseAdmin from './firebase-admin'\nimport {\n emailSuppressionKey,\n releaseEmailForConfirmedOptIn,\n} from './email-suppression'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\nfunction optOutDoc(hostId: string, key: string, firestore?: any) {\n return (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .collection(TOPIC_OPT_OUTS_SUBCOLLECTION)\n .doc(key)\n}\n\n/** What asking for a confirmation did. */\nexport type PendingConfirmationResult =\n /** Recorded. The address is not mailable about this topic until confirmed. */\n | 'pending'\n /** Already confirmed, or never needed confirming. Nothing was written. */\n | 'already-subscribed'\n /**\n * They left this stream. Nothing was written and nothing may be sent.\n *\n * A signup form cannot re-arm mail to somebody who unsubscribed by asking\n * them again: the confirmation request is itself a message, and sending it\n * would be mailing somebody who told this site to stop.\n */\n | 'opted-out'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Puts an address in the quarantine for one topic and returns the moment it\n * entered, which is what the confirmation link is signed and dated from.\n *\n * ## Re-asking does not restamp — inside the window\n *\n * A second signup from the same address inside the window keeps the original\n * `pendingAt`, so the 72-hour expiry measures from when we first asked. The\n * alternative lets somebody — or a script — hold an address in a permanently\n * fresh pending state by resubmitting a form, which is a way to keep a\n * confirmation link alive indefinitely.\n *\n * Once the window has passed, a signup is a new question and is stamped now.\n * An expired request admits nobody and its link no longer acts, so keeping\n * its moment would date the new link from the old one — expired before it is\n * sent — and \"sign up again\", which the expired-link page and the docs tell\n * the person to do, could never succeed. With a consent group waiting on its\n * sites' confirmations, that stranded request would hold the stream from\n * every site of the group.\n */\nexport async function recordPendingTopicConfirmation(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<{ result: PendingConfirmationResult; pendingAtMs: number | null }> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isSubscriptionTopicId(topicId)) {\n return { result: 'unusable', pendingAtMs: null }\n }\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n const address = String(email).trim().toLowerCase()\n let result: PendingConfirmationResult = 'pending'\n let pendingAtMs: number | null = nowMs\n await (options?.firestore ?? defaultFirestore()).runTransaction(\n async (transaction: any) => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') {\n result = 'opted-out'\n pendingAtMs = null\n return\n }\n if (state === 'pending') {\n // Already in the quarantine. Keep the original moment while that\n // request can still be confirmed — see above; past it, fall through\n // and ask again.\n const askedAtMs = Number(existing?.pendingAt) || nowMs\n if (!doubleOptInExpired(askedAtMs, nowMs)) {\n result = 'pending'\n pendingAtMs = askedAtMs\n return\n }\n }\n if (existing?.confirmedAt) {\n result = 'already-subscribed'\n pendingAtMs = null\n return\n }\n transaction.set(\n ref,\n {\n email: address,\n topics: {\n ...stored,\n [topicId]: {\n ...(existing ?? {}),\n pendingAt: nowMs,\n confirmedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists\n ? {}\n : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n },\n )\n return { result, pendingAtMs }\n}\n\n/** What a click on a confirmation link did. */\nexport type ConfirmTopicResult =\n /** They are now a subscriber to this topic. */\n | 'confirmed'\n /** They already were, and a second click changes nothing. */\n | 'already-confirmed'\n /** The request is older than the window, so the link no longer acts. */\n | 'expired'\n /** They left this stream. A confirmation link may not undo that. */\n | 'opted-out'\n /** Nothing was pending for this address and topic. */\n | 'not-pending'\n /** Not an address, or not a topic id. */\n | 'unusable'\n\n/**\n * Honors a confirmation click.\n *\n * ## An expired link does not admit anybody, and does not clear anything\n *\n * `expired` leaves the record exactly as it was: still pending, still\n * unmailable. Expiry is a property of the LINK, and a lapse that quietly\n * admitted the address, or quietly cleared the request, would make the window\n * a delay somebody could wait out rather than a gate.\n *\n * ## A confirmation cannot reverse an unsubscribe\n *\n * Same rule as `releaseSiteSuppression`: the recipient's later, more explicit\n * act stands, and a link minted before it must not undo it.\n *\n * ## A confirmation DOES reverse a hard bounce\n *\n * The one thing a completed round trip is evidence of. A `bounce` entry on\n * the platform list claims mail addressed here is permanently refused, and a\n * click on a link that was delivered to that mailbox is the only evidence\n * that could refute it. Without this, an address recorded as permanently\n * bounced — often a full mailbox or a misconfigured receiving server that a\n * provider reported as permanent — can never re-subscribe by any route, and\n * the double opt-in it just completed would enrol somebody the send path then\n * silently drops.\n *\n * The refusal above and the release here are the same rule seen from two\n * sides: a stated intention stands and a measurement does not. Which reasons\n * qualify is decided by `OPT_IN_RELEASABLE_REASONS`, not here — an unsubscribe\n * or a spam complaint is never among them, or this function would be a way to\n * launder a complaint through a signup form.\n *\n * The release is best-effort and deliberately after the transaction: the\n * confirmation is what the person is waiting for, and a suppression list that\n * could not be written must not cost them the subscription they just made.\n */\nexport async function confirmTopicSubscription(\n hostId: string,\n email: string,\n topicId: string,\n options?: { nowMs?: number; firestore?: any },\n): Promise<ConfirmTopicResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isSubscriptionTopicId(topicId)) return 'unusable'\n const nowMs = options?.nowMs ?? Date.now()\n const ref = optOutDoc(hostId, key, options?.firestore)\n /*\n * The verdict comes OUT of the transaction rather than through a closure\n * variable beside it. A transaction body can be retried, so a variable the\n * caller reads afterwards describes whichever attempt ran last rather than\n * the one that committed.\n */\n const result: ConfirmTopicResult = await (\n options?.firestore ?? defaultFirestore()\n ).runTransaction(async (transaction: any): Promise<ConfirmTopicResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const existing = stored[topicId]\n const state = readTopicSubscriptionState(existing)\n if (state === 'opted-out') return 'opted-out'\n if (state === 'subscribed') {\n return existing?.confirmedAt ? 'already-confirmed' : 'not-pending'\n }\n if (doubleOptInExpired(Number(existing?.pendingAt), nowMs)) {\n return 'expired'\n }\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: { ...(existing ?? {}), confirmedAt: nowMs },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n return 'confirmed'\n })\n if (result === 'confirmed') {\n await releaseEmailForConfirmedOptIn({\n email,\n hostId,\n topicId,\n ...(options?.firestore ? { firestore: options.firestore } : {}),\n })\n }\n return result\n}\n\n/**\n * Whether this site confirms new subscriptions by default.\n *\n * One host-document field, read at the moment somebody signs up and nowhere\n * else — the send path never asks, because by then the answer is already on\n * the person's own record as a pending mark or its absence. A read on every\n * send would be a read per recipient to re-derive a decision that was made\n * once.\n *\n * Fails to `false`, which is off. A default that a failed read could switch\n * ON would quarantine every new signup on a site whose owner never asked for\n * confirmations, and they would have no way to tell why their list stopped\n * growing.\n */\nexport async function siteRequiresDoubleOptIn(\n hostId: string,\n firestore?: any,\n): Promise<boolean> {\n if (!hostId) return false\n try {\n const snapshot = await (firestore ?? defaultFirestore())\n .collection('hosts')\n .doc(hostId)\n .get()\n return snapshot.exists && snapshot.get('emailDoubleOptIn') === true\n } catch (error) {\n console.error('[topic-subscriptions] double opt-in setting read failed', error)\n return false\n }\n}\n\n/** What asking to leave one topic did. */\nexport type TopicOptOutResult =\n /** Recorded now. The address is not mailable about this topic. */\n | 'opted-out'\n /** They had already left it; the moment they left is kept. */\n | 'already-opted-out'\n /** Not an address, or not a topic id. Nothing was written. */\n | 'unusable'\n\n/**\n * Takes one address off one topic on one site (AGL-2981) — the opt-out a\n * sender that is not the preference page records: a sales email's\n * unsubscribe link, a reply that asks to be left alone.\n *\n * The same record the preference page writes (`writeTopicOptOuts` in the\n * email plugin) and the send path reads, on the same rules:\n *\n * - an address that already left keeps the moment it left — a second\n * request is not a later one;\n * - the entry is carried forward rather than replaced, so a confirmation\n * the person once gave stays on the record beside the opt-out;\n * - a rejoin stamped earlier is cleared, because leaving is now the more\n * recent act.\n *\n * A transaction, and its verdict comes out of it rather than through a\n * variable beside it: a retried attempt must not describe itself as the one\n * that committed.\n */\nexport async function recordTopicOptOut(\n hostId: string,\n email: string,\n topicId: string,\n options?: { firestore?: any },\n): Promise<TopicOptOutResult> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId || !isSubscriptionTopicId(topicId)) return 'unusable'\n const db = options?.firestore ?? defaultFirestore()\n const ref = optOutDoc(hostId, key, db)\n return db.runTransaction(async (transaction: any): Promise<TopicOptOutResult> => {\n const snapshot = await transaction.get(ref)\n const stored = ((snapshot.exists ? snapshot.get('topics') : null) ??\n {}) as Record<string, TopicSubscriptionEntry>\n const previous = stored[topicId]\n if (readTopicSubscriptionState(previous) === 'opted-out') return 'already-opted-out'\n transaction.set(\n ref,\n {\n email: String(email).trim().toLowerCase(),\n topics: {\n ...stored,\n [topicId]: {\n ...(previous ?? {}),\n optedOutAt: FieldValue.serverTimestamp(),\n resubscribedAt: null,\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n ...(snapshot.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n { merge: true },\n )\n return 'opted-out'\n })\n}\n"],"names":["FieldValue","doubleOptInExpired","isSubscriptionTopicId","readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","firebaseAdmin","emailSuppressionKey","releaseEmailForConfirmedOptIn","defaultFirestore","app","firestore","optOutDoc","hostId","key","collection","doc","recordPendingTopicConfirmation","email","topicId","options","result","pendingAtMs","nowMs","Date","now","ref","address","String","trim","toLowerCase","runTransaction","transaction","snapshot","get","stored","exists","existing","state","askedAtMs","Number","pendingAt","confirmedAt","set","topics","updatedAt","serverTimestamp","createdAt","merge","confirmTopicSubscription","siteRequiresDoubleOptIn","error","console","recordTopicOptOut","db","previous","optedOutAt","resubscribedAt"],"mappings":"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"}
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
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";
17
+ */ import { CAPTURED_BY_HOST_FIELD, checkCrmRecordsQuota, consentGroupScope, CONTACT_FACETS_FIELD, CONTACT_FORM_IDS_CAP, CONTACT_FORM_IDS_FIELD, marketingConsentFieldsForGroup, mergeContactInteraction, normalizeContainerIds, 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
20
  import { countCrmRecords, restampCrmListFieldsAt } from "./crm-records.js";
@@ -211,7 +211,7 @@ export async function upsertHostContact(options) {
211
211
  * Normalized here rather than trusted, because it reaches this function
212
212
  * from a public endpoint's document read and every reader of the stored
213
213
  * array goes through the same coercion.
214
- */ const campaignIds = normalizeCampaignIds((_options_campaignIds = options.campaignIds) != null ? _options_campaignIds : []);
214
+ */ const campaignIds = normalizeContainerIds((_options_campaignIds = options.campaignIds) != null ? _options_campaignIds : []);
215
215
  /*
216
216
  * THE CUSTOM FIELD VALUES THIS CAPTURE CARRIES, as one nested map.
217
217
  *
@@ -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 type ConsentGroup,\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 { hostErasedEmails, hostRefusesCaptureForErasure } from './email-suppression'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\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 findContactsByEmail,\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 * The lookups of a whole file, read once (AGL-3423) — see\n * {@link prepareContactCaptureBatch}.\n *\n * A door capturing many addresses at once prepares one batch and hands it\n * to every capture; each one then takes the site, the group, the person\n * the address already is, the erasure and the band from it instead of\n * reading them. Every decision below is made by the same lines either way.\n * A batch prepared for another site is ignored.\n */\n batch?: ContactCaptureBatch\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 const batch =\n options.batch?.hostId === options.hostId ? options.batch : undefined\n // Contacts are org-scoped (AGL-237): every host in the org feeds one\n // shared list.\n const contactsRef: FirebaseFirestore.CollectionReference =\n batch?.contactsRef ??\n (await orgDataCollectionForHost(options.hostId, 'contacts'))\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 = batch?.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 prefetched = batch?.claim(email)\n const docSnapshot =\n prefetched !== undefined\n ? prefetched\n : 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 // The address's domain, checked after the response (AGL-3328).\n scheduleCapturedEmailCheck({\n orgId: contactsRef.parent?.id ?? null,\n hostId: options.hostId,\n email,\n predicted: docSnapshot.get('emailStatus') === 'undeliverable',\n })\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 (\n batch?.erased(email) ??\n (await hostRefusesCaptureForErasure(options.hostId, email, firestore))\n ) {\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 //\n // A batch counted once and keeps the tally, in call order, so each of\n // its creates is admitted exactly as it would be here one at a time.\n const orgBilling = batch\n ? batch.orgBilling\n : await getOrgForHost(options.hostId)\n const admitted = batch\n ? batch.admitCreate(email)\n : await (async () => {\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const { crmRecordsCount } = await countCrmRecords(orgRef, contactsRef)\n return checkCrmRecordsQuota(\n (orgBilling?.org as any) ?? null,\n crmRecordsCount,\n ).allowed\n })()\n if (!admitted) {\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 // A new person's address, checked after the response (AGL-3328): a\n // domain that takes no mail reads \"Would bounce\" on the record.\n scheduleCapturedEmailCheck({\n orgId: contactsRef.parent?.id ?? null,\n hostId: options.hostId,\n email,\n })\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('upsertHostContact failed', error)\n return { refused: 'error' }\n }\n}\n\n/*\n * THE LOOKUPS OF MANY CAPTURES, PAID ONCE (AGL-3423).\n *\n * `upsertHostContact` is the one writer of the contacts collection, and for\n * a single capture it resolves everything it decides with on its own: the\n * site's org and consent group, the person the address already belongs to,\n * whether the site erased them, and how full the records band is. For a\n * form submission that is a handful of reads. For a file it was the same\n * handful again for every row, serially — a two-hundred-row chunk of an\n * Apollo export was thousands of round trips and three aggregate counts per\n * new person, and the request ran into the function's sixty seconds before\n * it could answer.\n *\n * A batch is those answers for every address in a request, read in a\n * number of round trips that does not grow with the file. The door is\n * handed it as {@link UpsertHostContactOptions.batch} and consults it in\n * place of each per-capture read; every decision is still the door's own,\n * made by the same code, so an import cannot drift into a second writer\n * with its own ideas about dedupe, consent, scope or the band.\n *\n * ## What a batch answers, and what it leaves to the door\n *\n * - **Who the address already is.** {@link findContactsByEmail} — the index\n * and the `email` query, for every address at once. `claim` hands an\n * address's answer out ONCE: a second capture of the same address in the\n * same batch is asked live, so a create earlier in the batch is found\n * rather than minted twice.\n * - **Whether the site erased them.** {@link hostErasedEmails}, one read.\n * - **Room in the records band.** One count, then a running tally in call\n * order, so N creates admit exactly as N serial door calls would. A\n * caller that creates OTHER records against the same band — the import's\n * companies — spends from the same tally with {@link ContactCaptureBatch.admit},\n * and may {@link ContactCaptureBatch.reserve} an address's room at its\n * place in the file, so the order in which rows are later written does\n * not decide which of them the band keeps.\n *\n * Any answer a batch does not have — a page of the lookup that failed, an\n * address it was not prepared with — is `undefined`, and the door reads for\n * itself exactly as it does without a batch.\n */\n\n/** The resolved answers a batch of captures on one site share. */\nexport interface ContactCaptureBatch {\n /** The site every capture in the batch is on; a door for another site ignores the batch. */\n readonly hostId: string\n readonly contactsRef: FirebaseFirestore.CollectionReference\n readonly group: ConsentGroup\n readonly orgBilling: Awaited<ReturnType<typeof getOrgForHost>>\n /**\n * What an address resolved to when the batch was prepared, without\n * spending it: the record, `null` for nobody, `undefined` for not known.\n */\n peek(email: string): FirebaseFirestore.DocumentSnapshot | null | undefined\n /**\n * The same answer, handed out once. Every later claim of the address is\n * `undefined`, so its door looks it up live.\n */\n claim(email: string): FirebaseFirestore.DocumentSnapshot | null | undefined\n /** Whether the site erased the address; `undefined` when the read failed. */\n erased(email: string): boolean | undefined\n /** Takes room for one more record in the band, now. False when the band refuses. */\n admit(): boolean\n /** Takes the address's room now, at its place in the caller's order. */\n reserve(email: string): boolean\n /** The door's question: the reserved answer when there is one, else {@link admit}. */\n admitCreate(email: string): boolean\n}\n\n/**\n * Reads, once, what every capture of `emails` on `hostId` would read for\n * itself. Throws only when the site or the band cannot be read at all —\n * the reads every capture would fail on — so a caller answers the request\n * rather than refusing every row one at a time.\n */\nexport async function prepareContactCaptureBatch(\n hostId: string,\n emails: readonly unknown[],\n): Promise<ContactCaptureBatch> {\n const firestore = firebaseAdmin.app().firestore()\n const [contactsRef, orgBilling] = await Promise.all([\n orgDataCollectionForHost(hostId, 'contacts'),\n getOrgForHost(hostId),\n ])\n const group = await consentGroupForSite(\n hostId,\n (orgBilling?.org as Record<string, unknown> | undefined) ?? null,\n )\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const addresses = [\n ...new Set(\n emails\n .map((email) => normalizeContactEmail(email))\n .filter((email): email is string => !!email),\n ),\n ]\n const [existing, erased, counted] = await Promise.all([\n findContactsByEmail(contactsRef, addresses),\n hostErasedEmails(hostId, addresses, firestore),\n countCrmRecords(orgRef, contactsRef),\n ])\n\n const org = (orgBilling?.org as never) ?? null\n let used = counted.crmRecordsCount\n const claimed = new Set<string>()\n const tickets = new Map<string, boolean>()\n const admit = (): boolean => {\n if (!checkCrmRecordsQuota(org, used).allowed) return false\n used += 1\n return true\n }\n\n return {\n hostId,\n contactsRef,\n group,\n orgBilling,\n peek: (email) => existing.get(email),\n claim: (email) => {\n if (claimed.has(email)) return undefined\n claimed.add(email)\n return existing.get(email)\n },\n erased: (email) => (erased ? erased.has(email) : undefined),\n admit,\n reserve: (email) => {\n const ticket = tickets.get(email)\n if (ticket !== undefined) return ticket\n const admitted = admit()\n tickets.set(email, admitted)\n return admitted\n },\n admitCreate: (email) => {\n const ticket = tickets.get(email)\n if (ticket === undefined) return admit()\n tickets.delete(email)\n return ticket\n },\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","hostErasedEmails","hostRefusesCaptureForErasure","scheduleCapturedEmailCheck","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","findContactsByEmail","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","batch","group","consentExtra","marketingConsentSource","consentFields","marketingConsent","disclosedConsentGroup","declineMarketingConsent","type","summary","formId","path","campaignIds","customEntries","facet","customFacet","fromEntries","prefetched","claim","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","orgId","id","predicted","contactId","created","erased","admitted","admitCreate","orgRef","crmRecordsCount","org","allowed","total","record","defaultResourceScope","createdAt","add","companyIds","heldElsewhere","campaignTouch","kind","touch","convertedAtMs","onCreated","Promise","resolve","error","console","prepareContactCaptureBatch","emails","all","addresses","existing","counted","used","claimed","tickets","Map","admit","peek","has","reserve","ticket","delete"],"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 type ConsentGroup,\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 normalizeContainerIds,\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 { hostErasedEmails, hostRefusesCaptureForErasure } from './email-suppression'\nimport { scheduleCapturedEmailCheck } from './capture-email-check'\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 findContactsByEmail,\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 * The lookups of a whole file, read once (AGL-3423) — see\n * {@link prepareContactCaptureBatch}.\n *\n * A door capturing many addresses at once prepares one batch and hands it\n * to every capture; each one then takes the site, the group, the person\n * the address already is, the erasure and the band from it instead of\n * reading them. Every decision below is made by the same lines either way.\n * A batch prepared for another site is ignored.\n */\n batch?: ContactCaptureBatch\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 const batch =\n options.batch?.hostId === options.hostId ? options.batch : undefined\n // Contacts are org-scoped (AGL-237): every host in the org feeds one\n // shared list.\n const contactsRef: FirebaseFirestore.CollectionReference =\n batch?.contactsRef ??\n (await orgDataCollectionForHost(options.hostId, 'contacts'))\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 = batch?.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 = normalizeContainerIds(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 prefetched = batch?.claim(email)\n const docSnapshot =\n prefetched !== undefined\n ? prefetched\n : 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 `container-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 * `contactContainerFieldPath`, 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 // The address's domain, checked after the response (AGL-3328).\n scheduleCapturedEmailCheck({\n orgId: contactsRef.parent?.id ?? null,\n hostId: options.hostId,\n email,\n predicted: docSnapshot.get('emailStatus') === 'undeliverable',\n })\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 (\n batch?.erased(email) ??\n (await hostRefusesCaptureForErasure(options.hostId, email, firestore))\n ) {\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 //\n // A batch counted once and keeps the tally, in call order, so each of\n // its creates is admitted exactly as it would be here one at a time.\n const orgBilling = batch\n ? batch.orgBilling\n : await getOrgForHost(options.hostId)\n const admitted = batch\n ? batch.admitCreate(email)\n : await (async () => {\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const { crmRecordsCount } = await countCrmRecords(orgRef, contactsRef)\n return checkCrmRecordsQuota(\n (orgBilling?.org as any) ?? null,\n crmRecordsCount,\n ).allowed\n })()\n if (!admitted) {\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 // A new person's address, checked after the response (AGL-3328): a\n // domain that takes no mail reads \"Would bounce\" on the record.\n scheduleCapturedEmailCheck({\n orgId: contactsRef.parent?.id ?? null,\n hostId: options.hostId,\n email,\n })\n return { contactId: created.id, created: true }\n } catch (error) {\n console.error('upsertHostContact failed', error)\n return { refused: 'error' }\n }\n}\n\n/*\n * THE LOOKUPS OF MANY CAPTURES, PAID ONCE (AGL-3423).\n *\n * `upsertHostContact` is the one writer of the contacts collection, and for\n * a single capture it resolves everything it decides with on its own: the\n * site's org and consent group, the person the address already belongs to,\n * whether the site erased them, and how full the records band is. For a\n * form submission that is a handful of reads. For a file it was the same\n * handful again for every row, serially — a two-hundred-row chunk of an\n * Apollo export was thousands of round trips and three aggregate counts per\n * new person, and the request ran into the function's sixty seconds before\n * it could answer.\n *\n * A batch is those answers for every address in a request, read in a\n * number of round trips that does not grow with the file. The door is\n * handed it as {@link UpsertHostContactOptions.batch} and consults it in\n * place of each per-capture read; every decision is still the door's own,\n * made by the same code, so an import cannot drift into a second writer\n * with its own ideas about dedupe, consent, scope or the band.\n *\n * ## What a batch answers, and what it leaves to the door\n *\n * - **Who the address already is.** {@link findContactsByEmail} — the index\n * and the `email` query, for every address at once. `claim` hands an\n * address's answer out ONCE: a second capture of the same address in the\n * same batch is asked live, so a create earlier in the batch is found\n * rather than minted twice.\n * - **Whether the site erased them.** {@link hostErasedEmails}, one read.\n * - **Room in the records band.** One count, then a running tally in call\n * order, so N creates admit exactly as N serial door calls would. A\n * caller that creates OTHER records against the same band — the import's\n * companies — spends from the same tally with {@link ContactCaptureBatch.admit},\n * and may {@link ContactCaptureBatch.reserve} an address's room at its\n * place in the file, so the order in which rows are later written does\n * not decide which of them the band keeps.\n *\n * Any answer a batch does not have — a page of the lookup that failed, an\n * address it was not prepared with — is `undefined`, and the door reads for\n * itself exactly as it does without a batch.\n */\n\n/** The resolved answers a batch of captures on one site share. */\nexport interface ContactCaptureBatch {\n /** The site every capture in the batch is on; a door for another site ignores the batch. */\n readonly hostId: string\n readonly contactsRef: FirebaseFirestore.CollectionReference\n readonly group: ConsentGroup\n readonly orgBilling: Awaited<ReturnType<typeof getOrgForHost>>\n /**\n * What an address resolved to when the batch was prepared, without\n * spending it: the record, `null` for nobody, `undefined` for not known.\n */\n peek(email: string): FirebaseFirestore.DocumentSnapshot | null | undefined\n /**\n * The same answer, handed out once. Every later claim of the address is\n * `undefined`, so its door looks it up live.\n */\n claim(email: string): FirebaseFirestore.DocumentSnapshot | null | undefined\n /** Whether the site erased the address; `undefined` when the read failed. */\n erased(email: string): boolean | undefined\n /** Takes room for one more record in the band, now. False when the band refuses. */\n admit(): boolean\n /** Takes the address's room now, at its place in the caller's order. */\n reserve(email: string): boolean\n /** The door's question: the reserved answer when there is one, else {@link admit}. */\n admitCreate(email: string): boolean\n}\n\n/**\n * Reads, once, what every capture of `emails` on `hostId` would read for\n * itself. Throws only when the site or the band cannot be read at all —\n * the reads every capture would fail on — so a caller answers the request\n * rather than refusing every row one at a time.\n */\nexport async function prepareContactCaptureBatch(\n hostId: string,\n emails: readonly unknown[],\n): Promise<ContactCaptureBatch> {\n const firestore = firebaseAdmin.app().firestore()\n const [contactsRef, orgBilling] = await Promise.all([\n orgDataCollectionForHost(hostId, 'contacts'),\n getOrgForHost(hostId),\n ])\n const group = await consentGroupForSite(\n hostId,\n (orgBilling?.org as Record<string, unknown> | undefined) ?? null,\n )\n const orgRef =\n contactsRef.parent ??\n firestore.collection('orgs').doc(String(orgBilling?.orgId ?? ''))\n const addresses = [\n ...new Set(\n emails\n .map((email) => normalizeContactEmail(email))\n .filter((email): email is string => !!email),\n ),\n ]\n const [existing, erased, counted] = await Promise.all([\n findContactsByEmail(contactsRef, addresses),\n hostErasedEmails(hostId, addresses, firestore),\n countCrmRecords(orgRef, contactsRef),\n ])\n\n const org = (orgBilling?.org as never) ?? null\n let used = counted.crmRecordsCount\n const claimed = new Set<string>()\n const tickets = new Map<string, boolean>()\n const admit = (): boolean => {\n if (!checkCrmRecordsQuota(org, used).allowed) return false\n used += 1\n return true\n }\n\n return {\n hostId,\n contactsRef,\n group,\n orgBilling,\n peek: (email) => existing.get(email),\n claim: (email) => {\n if (claimed.has(email)) return undefined\n claimed.add(email)\n return existing.get(email)\n },\n erased: (email) => (erased ? erased.has(email) : undefined),\n admit,\n reserve: (email) => {\n const ticket = tickets.get(email)\n if (ticket !== undefined) return ticket\n const admitted = admit()\n tickets.set(email, admitted)\n return admitted\n },\n admitCreate: (email) => {\n const ticket = tickets.get(email)\n if (ticket === undefined) return admit()\n tickets.delete(email)\n return ticket\n },\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkCrmRecordsQuota","consentGroupScope","CONTACT_FACETS_FIELD","CONTACT_FORM_IDS_CAP","CONTACT_FORM_IDS_FIELD","marketingConsentFieldsForGroup","mergeContactInteraction","normalizeContainerIds","readContactFacet","normalizeContactEmail","ORG_SCOPE_TOKEN","FieldValue","firebaseAdmin","countCrmRecords","restampCrmListFieldsAt","attributeOrderToEmail","hostErasedEmails","hostRefusesCaptureForErasure","scheduleCapturedEmailCheck","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","findContactsByEmail","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","batch","group","consentExtra","marketingConsentSource","consentFields","marketingConsent","disclosedConsentGroup","declineMarketingConsent","type","summary","formId","path","campaignIds","customEntries","facet","customFacet","fromEntries","prefetched","claim","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","orgId","id","predicted","contactId","created","erased","admitted","admitCreate","orgRef","crmRecordsCount","org","allowed","total","record","defaultResourceScope","createdAt","add","companyIds","heldElsewhere","campaignTouch","kind","touch","convertedAtMs","onCreated","Promise","resolve","error","console","prepareContactCaptureBatch","emails","all","addresses","existing","counted","used","claimed","tickets","Map","admit","peek","has","reserve","ticket","delete"],"mappings":"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"}
package/src/tenancy.d.ts CHANGED
@@ -38,7 +38,7 @@ export * from './lib/server/csp-aggregate';
38
38
  export * from './lib/server/data-storage-gate';
39
39
  export * from './lib/server/contact-suppression';
40
40
  export * from './lib/server/email-suppression';
41
- export * from './lib/server/email-topic-confirmation';
41
+ export * from './lib/server/topic-subscriptions';
42
42
  export * from './lib/server/email-unsubscribe-link';
43
43
  export * from './lib/server/email-flow-gate';
44
44
  export * from './lib/server/email-marketing-gate';
@@ -117,8 +117,6 @@ export * from './lib/server/upsert-contact';
117
117
  export * from './lib/server/crm-records';
118
118
  export * from './lib/server/crm-next-activity';
119
119
  export * from './lib/server/crm-email-activity';
120
- export * from './lib/server/crm-booking-activity';
121
- export * from './lib/server/crm-inbound-email';
122
120
  export * from './lib/server/member-email-aliases';
123
121
  export * from './lib/server/user-profiles';
124
122
  export * from './lib/server/new-account-announcement';
package/src/tenancy.js CHANGED
@@ -46,7 +46,7 @@ export * from "./lib/server/email-suppression.js";
46
46
  // The double opt-in quarantine, after the module whose keying it shares and
47
47
  // whose `filterTopicSendable` is the reader that makes it a quarantine rather
48
48
  // than a stored fact.
49
- export * from "./lib/server/email-topic-confirmation.js";
49
+ export * from "./lib/server/topic-subscriptions.js";
50
50
  export * from "./lib/server/email-unsubscribe-link.js";
51
51
  export * from "./lib/server/email-flow-gate.js";
52
52
  // Side-effecting on purpose, for the reason `email-send-rate` below is:
@@ -148,12 +148,6 @@ export * from "./lib/server/crm-next-activity.js";
148
148
  // Where a one-to-one email's activity row lives and how the delivery
149
149
  // webhook advances it (AGL-2615).
150
150
  export * from "./lib/server/crm-email-activity.js";
151
- // What a confirmed booking files on the CRM record — the meeting and the
152
- // follow-up (AGL-2660).
153
- export * from "./lib/server/crm-booking-activity.js";
154
- // The org's email capture token and the filing of one received message
155
- // on the record it was with (AGL-2657).
156
- export * from "./lib/server/crm-inbound-email.js";
157
151
  // A member's own addresses in a workspace, and the signed link that
158
152
  // confirms one (AGL-2975).
159
153
  export * from "./lib/server/member-email-aliases.js";