@aglyn/tenant-data-admin 1.0.0-beta.195 → 1.0.0-beta.197

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 (32) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/consent-group-carry.d.ts +50 -0
  3. package/src/lib/server/consent-group-carry.js +319 -0
  4. package/src/lib/server/consent-group-carry.js.map +1 -0
  5. package/src/lib/server/consent-group-change.d.ts +246 -0
  6. package/src/lib/server/consent-group-change.js +1096 -0
  7. package/src/lib/server/consent-group-change.js.map +1 -0
  8. package/src/lib/server/dynamic-list-materialize.js +31 -5
  9. package/src/lib/server/dynamic-list-materialize.js.map +1 -1
  10. package/src/lib/server/email-suppression.d.ts +32 -0
  11. package/src/lib/server/email-suppression.js +40 -2
  12. package/src/lib/server/email-suppression.js.map +1 -1
  13. package/src/lib/server/erase.d.ts +21 -0
  14. package/src/lib/server/erase.js +32 -1
  15. package/src/lib/server/erase.js.map +1 -1
  16. package/src/lib/server/host-visitor-records.d.ts +7 -0
  17. package/src/lib/server/host-visitor-records.js +9 -3
  18. package/src/lib/server/host-visitor-records.js.map +1 -1
  19. package/src/lib/server/list-members.d.ts +23 -0
  20. package/src/lib/server/list-members.js +49 -7
  21. package/src/lib/server/list-members.js.map +1 -1
  22. package/src/lib/server/notifications.js +4 -1
  23. package/src/lib/server/notifications.js.map +1 -1
  24. package/src/lib/server/test-firestore-queries.d.ts +42 -0
  25. package/src/lib/server/test-firestore-queries.js +640 -0
  26. package/src/lib/server/test-firestore-queries.js.map +1 -0
  27. package/src/lib/server/upsert-contact.d.ts +15 -1
  28. package/src/lib/server/upsert-contact.js +8 -2
  29. package/src/lib/server/upsert-contact.js.map +1 -1
  30. package/src/tenancy.d.ts +2 -0
  31. package/src/tenancy.js +4 -0
  32. package/src/tenancy.js.map +1 -1
@@ -184,6 +184,13 @@ export interface HostLeadInput {
184
184
  * checkbox.
185
185
  */
186
186
  marketingConsent?: boolean;
187
+ /**
188
+ * The `consentGroupDisclosureKey` the capture surface rendered beside that
189
+ * checkbox (AGL-3320). The grant pools across the site's consent group only
190
+ * when it is the group's current key, and is this site's alone otherwise —
191
+ * the same rule `upsertHostContact` applies.
192
+ */
193
+ disclosedConsentGroup?: string | null;
187
194
  }
188
195
  /**
189
196
  * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by
@@ -29,6 +29,9 @@ import { notifyHostManagers } from "./notifications.js";
29
29
  import firebaseAdmin from "./firebase-admin.js";
30
30
  import { consentGroupForSite, orgDataCollectionForHost, scopedToHost } from "./organizations.js";
31
31
  import { crmReadTokens, crmScopeTokens } from "@aglyn/aglyn/server";
32
+ // The module path rather than the barrel, as `upsert-contact.ts` does: a spec
33
+ // that substitutes the barrel keeps the real rule for which group a grant is.
34
+ import { consentGroupForGrant } from "@aglyn/aglyn/app-utils/consent-groups";
32
35
  const firestore = ()=>firebaseAdmin.app().firestore();
33
36
  /**
34
37
  * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).
@@ -360,8 +363,11 @@ const firestore = ()=>firebaseAdmin.app().firestore();
360
363
  * multi-brand org holding a lead its sibling brand can SEE but may not
361
364
  * MAIL — a narrower basis than the one `consentGroupDisclosure` named
362
365
  * beside the checkbox this person ticked. Pooling here records what
363
- * they were actually told; an undeclared group is still a group of one,
364
- * so an agency is unchanged and configures nothing.
366
+ * they were actually told, and only that: the grant reaches the group
367
+ * when the capture surface sent back the group's current disclosure
368
+ * key (AGL-3320), and this site alone otherwise. An undeclared group is
369
+ * still a group of one, so an agency is unchanged and configures
370
+ * nothing.
365
371
  *
366
372
  * Resolved above the transaction — see `group`'s declaration.
367
373
  */ const prior = readMarketingBasis((_existing_data = existing.data()) != null ? _existing_data : null, group);
@@ -394,7 +400,7 @@ const firestore = ()=>firebaseAdmin.app().firestore();
394
400
  }, existing.exists ? {} : {
395
401
  firstSeenAtMs: now,
396
402
  createdAt: FieldValue.serverTimestamp()
397
- }, lead.marketingConsent ? marketingConsentFieldsForGroup(group, consentAtMs) : {}), {
403
+ }, lead.marketingConsent ? marketingConsentFieldsForGroup(consentGroupForGrant(group, lead.disclosedConsentGroup), consentAtMs) : {}), {
398
404
  merge: true
399
405
  });
400
406
  return false;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n personKey,\n readMarketingBasis,\n submissionMonthKey,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { notifyHostManagers } from './notifications'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n scopedToHost,\n} from './organizations'\nimport { crmReadTokens, crmScopeTokens } from '@aglyn/aglyn/server'\nimport type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).\n *\n * ## Why leads moved\n *\n * A lead used to live at `hosts/{hostId}/leads/{personKey}` while the\n * contact for the same human lived on the org with a `visibleTo` array. The\n * path was doing the job `visibleTo` does, and doing it worse:\n *\n * - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,\n * with no configuration — `consentGroupScope` on a group of one is\n * `['host:{id}']`.\n * - A MULTI-BRAND org could not be served at all. `personKey` is derived\n * from the address, so the same person captured on two sibling brands\n * produced two documents with byte-identical ids under different parents.\n * One person was two records by construction, which is the thing AGL-3232\n * had just finished eliminating for contacts.\n * - The two answers could disagree. A person's contact was visible to every\n * brand in the consent group they actually consented to — the disclosure\n * names those brands — while their lead was visible to whichever brand's\n * form they happened to land on. The lead was scoped more narrowly than\n * what the person was told, which is the safe direction to be wrong in and\n * still the wrong answer.\n *\n * So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,\n * the same pair the contacts surface uses. Nothing here is a new mechanism;\n * this is leads arriving at the one the rest of the CRM already had.\n *\n * ## The fallback is READ-ONLY, and temporary\n *\n * This repo has run this migration once already — AGL-237 moved datasets,\n * contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the\n * fallback — and `orgDataCollectionForHost` carries what it learned:\n *\n * \"a second storage path that can still be WRITTEN is a second boundary to\n * enforce forever, which undoes the premise of scoped sharing: one home\n * per resource plus an explicit scope.\"\n *\n * So the host path is never written here. It is read, once, to carry a\n * not-yet-migrated lead onto the org as a side effect of the next write that\n * touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill\n * has left to fold, and means no read can be answered by a stale host row\n * after a write has moved on. AGL-3277 deletes the fallback and the rules\n * block behind it once that backfill reports nothing left to plan.\n */\n\n\n/**\n * The org collection a lead is written to. Always the org — see the\n * read-only note above.\n */\nexport async function orgLeadsForHost(\n hostId: string,\n): Promise<FirebaseFirestore.CollectionReference> {\n return orgDataCollectionForHost(hostId, 'leads')\n}\n\n/**\n * The leads one site may LIST, narrowed by `visibleTo`.\n *\n * The Admin SDK does not evaluate rules, so an unnarrowed read here would\n * serve one agency client's leads on another's site — the same reason\n * `scopedToHost` exists for every other org-owned collection.\n */\nexport async function orgLeadsQueryForHost(hostId: string): Promise<{\n ref: FirebaseFirestore.CollectionReference\n query: FirebaseFirestore.Query\n}> {\n const ref = await orgLeadsForHost(hostId)\n return { ref, query: scopedToHost(ref, hostId) }\n}\n\n/**\n * The tokens a reader holding this group may ask `array-contains-any` for.\n * Capped at 30 by `crmReadTokens`, as the contacts list is.\n */\nexport async function leadReadTokensForHost(\n hostId: string,\n group?: ConsentGroup,\n): Promise<ScopeToken[]> {\n return crmReadTokens(group ?? (await consentGroupForSite(hostId)))\n}\n\n/**\n * The `visibleTo` a capture on this site stamps: the consent group's sites,\n * or `['org']` where the org set `defaultResourceScope`.\n *\n * Widened by the capture, never by the lookup — a site that has never\n * captured this person gains nothing by finding them, which is what keeps an\n * agency's clients apart on a record they share. Callers UPDATING an\n * existing lead union these in rather than replacing, exactly as\n * `upsert-contact` does.\n */\nexport async function leadScopeForHost(\n hostId: string,\n org?: Record<string, unknown> | null,\n): Promise<ScopeToken[]> {\n const group = await consentGroupForSite(hostId, org)\n return crmScopeTokens(org ?? null, group)\n}\n\n/**\n * The lead for an address, wherever it currently lives.\n *\n * ## The lookup is UNSCOPED, and has to be\n *\n * One human who touched two sibling brands is one person. Narrowing this to\n * what the capturing site may already see would let a second submission on a\n * sibling brand mint a SECOND record for the same address — the duplication\n * this migration exists to end, reintroduced one layer up. Recognizing\n * somebody and being allowed to read their row are different acts:\n * this finds the person, {@link leadScopeForHost} decides who may see them.\n *\n * `upsert-contact` states the same rule at its own dedupe lookup, and for the\n * same reason. A caller that wants only what a site may SEE wants\n * {@link orgLeadsQueryForHost}.\n *\n * @returns the org row; else the not-yet-migrated host row; else `null`.\n */\nexport async function readLeadForHost(\n hostId: string,\n key: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get()\n return orgRow.exists ? orgRow : null\n}\n\n/**\n * The ref a write should target.\n *\n * This carried a not-yet-backfilled host row onto the org first, and reported\n * whether it had (`carried`). AGL-3276 emptied the host path and AGL-3277\n * removed it, so there is nothing left to carry: the org row is the only row.\n * `carried` stays on the shape, always `false`, because several callers\n * destructure it and a lie is cheaper to read than a signature change that\n * says nothing.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n return { ref, existed: (await ref.get()).exists, carried: false }\n}\n\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One in-app notification to the site's managers, on the FIRST refusal of\n * the month only. A notification per refused bot request would be the\n * flood again, delivered.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". Only the second is worth a notice.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? submissionMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n const alreadyRefused = Number(refusedSnapshot.get(monthKey) ?? 0)\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: Date.now(),\n },\n { merge: true },\n )\n if (alreadyRefused === 0) {\n const leads = kind === 'leads'\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused — this site is at the platform limit'\n : 'Sign-ups paused — this site is at the platform limit',\n body:\n `This site holds ${ceiling.toLocaleString()} ` +\n `${leads ? 'leads' : 'member accounts'}, which is the platform ` +\n `safety limit, so further ${leads ? 'leads' : 'sign-ups'} are ` +\n 'being refused. This is not part of your plan — every plan ' +\n `includes unlimited ${leads ? 'leads' : 'member accounts'}. ` +\n `Remove some ${leads ? 'leads' : 'members'}, or contact support ` +\n 'if this is real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLead(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: ResolvedCampaignTouch | null\n}): Promise<boolean> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = await orgLeadsForHost(hostId)\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n /*\n * A lead the org has not taken over yet is carried across BEFORE the\n * transaction opens (AGL-3275). It has to happen outside: the carry is\n * itself a read-then-write, and a transaction that also counted the\n * collection would be reading a row it was about to create. Two doors\n * carrying the same person in the same second both write the legacy\n * fields under `merge`, so the race is a no-op rather than a conflict.\n */\n const leadRef = key\n ? (await leadForWrite(hostId, key)).ref\n : leadsRef.doc()\n /*\n * The scope this capture stamps, and the group whose terms its consent is\n * recorded under. Both resolved HERE rather than in the transaction body:\n * a contended transaction re-runs that body, and neither of these can\n * change between attempts, so resolving them inside would pay for the org\n * read again on every retry.\n */\n const scope = await leadScopeForHost(hostId)\n const group = await consentGroupForSite(hostId)\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n /*\n * COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).\n *\n * The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a\n * per-SITE ceiling. An unfiltered count would charge every brand in\n * an agency's account for every other brand's leads, and the first\n * client to fill its allowance would refuse captures on sites that\n * had taken none. `scopedToHost` is the same narrowing every other\n * org-owned read uses, so a single-site org counts exactly what it\n * counted before this moved.\n *\n * A lead shared by two brands in one consent group is counted by\n * both, which is the honest answer: each of them holds it.\n */\n const used = (\n await tx.get(scopedToHost(leadsRef, hostId).count())\n ).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE SITE'S REAL GROUP, because the silo that justified a group of one\n * is gone (AGL-3275).\n *\n * This read `soloConsentGroup(hostId)` for as long as a lead lived at\n * `hosts/{hostId}/leads`, and the reason was the path: private by\n * construction, so pooling a lead's basis would have recorded a\n * disclosure that reached nothing, while the contact written by the\n * same capture door was org-shared and pooled.\n *\n * A lead is now org-shared on exactly the terms the contact is, so the\n * premise is false and keeping the group of one would leave a\n * multi-brand org holding a lead its sibling brand can SEE but may not\n * MAIL — a narrower basis than the one `consentGroupDisclosure` named\n * beside the checkbox this person ticked. Pooling here records what\n * they were actually told; an undeclared group is still a group of one,\n * so an agency is unchanged and configures nothing.\n *\n * Resolved above the transaction — see `group`'s declaration.\n */\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n /*\n * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).\n *\n * `arrayUnion` rather than a replace, exactly as `upsert-contact`\n * stamps a contact: this site just collected this person, so it may\n * see the row. A site that merely FOUND them — the unscoped dedupe\n * lookup in `readLeadForHost` — gains nothing, which is what keeps\n * an agency's clients apart on a record they share.\n */\n visibleTo: FieldValue.arrayUnion(...scope),\n /*\n * EVERY SITE THAT CAPTURED THIS PERSON, not just the first\n * (AGL-3275).\n *\n * This was written once, on create, and that was sound while the\n * collection sat under one host and could hold only that host's\n * name. On the org the row is shared, so a sibling brand capturing\n * a person the first brand already holds has to be recorded here or\n * the \"Known by\" answer silently omits it — the same `arrayUnion`\n * the contact door has always used for this field, now that a lead\n * has the same question to answer.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId),\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n }),\n /*\n * The basis is recorded under the GROUP, keyed by its host, because\n * {@link readMarketingBasis} is one function over four silos and a\n * silo whose basis lived somewhere else would need the reader to\n * know which collection it was handed. A reader that has to be told\n * the shape is a reader that can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(group, consentAtMs)\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return false\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n if (created && options.touch) {\n await attributeCampaignConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return true\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return false\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","personKey","readMarketingBasis","submissionMonthKey","visitorRecordRefusedCounterId","FieldValue","attributeCampaignConversion","notifyHostManagers","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","scopedToHost","crmReadTokens","crmScopeTokens","firestore","app","orgLeadsForHost","hostId","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","doc","get","exists","leadForWrite","existed","carried","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedSnapshot","refusedRef","collection","alreadyRefused","Number","set","increment","lastRefusedAtMs","Date","now","merge","leads","type","title","body","toLocaleString","link","error","console","addHostLead","lead","maxPerHost","leadsRef","email","leadRef","scope","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","refused","runTransaction","tx","existing","used","count","data","exceeded","prior","consentAtMs","basis","basisAtMs","visibleTo","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","touch","refId","id","convertedAtMs"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/host-visitor-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The durable half of the visitor-record ceilings (AGL-1529) — the lead\n * writer that enforces `LEADS_MAX_PER_HOST`, and the bookkeeping that makes a\n * trip of EITHER ceiling something a site's owner can see.\n *\n * The policy is pure and lives in `@aglyn/aglyn/server`\n * (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same\n * split as `visitorWriteRateLimitRefusal`, and for the same reason.\n */\n\nimport {\n CAPTURED_BY_HOST_FIELD,\n checkVisitorRecordCeiling,\n LEADS_MAX_PER_HOST,\n marketingConsentFieldsForGroup,\n personKey,\n readMarketingBasis,\n submissionMonthKey,\n visitorRecordRefusedCounterId,\n type VisitorRecordKind,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n attributeCampaignConversion,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { notifyHostManagers } from './notifications'\nimport firebaseAdmin from './firebase-admin'\nimport {\n consentGroupForSite,\n orgDataCollectionForHost,\n resolveOrgIdForHost,\n scopedToHost,\n} from './organizations'\nimport { crmReadTokens, crmScopeTokens } from '@aglyn/aglyn/server'\nimport type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server'\n// The module path rather than the barrel, as `upsert-contact.ts` does: a spec\n// that substitutes the barrel keeps the real rule for which group a grant is.\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\n/**\n * A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).\n *\n * ## Why leads moved\n *\n * A lead used to live at `hosts/{hostId}/leads/{personKey}` while the\n * contact for the same human lived on the org with a `visibleTo` array. The\n * path was doing the job `visibleTo` does, and doing it worse:\n *\n * - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,\n * with no configuration — `consentGroupScope` on a group of one is\n * `['host:{id}']`.\n * - A MULTI-BRAND org could not be served at all. `personKey` is derived\n * from the address, so the same person captured on two sibling brands\n * produced two documents with byte-identical ids under different parents.\n * One person was two records by construction, which is the thing AGL-3232\n * had just finished eliminating for contacts.\n * - The two answers could disagree. A person's contact was visible to every\n * brand in the consent group they actually consented to — the disclosure\n * names those brands — while their lead was visible to whichever brand's\n * form they happened to land on. The lead was scoped more narrowly than\n * what the person was told, which is the safe direction to be wrong in and\n * still the wrong answer.\n *\n * So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,\n * the same pair the contacts surface uses. Nothing here is a new mechanism;\n * this is leads arriving at the one the rest of the CRM already had.\n *\n * ## The fallback is READ-ONLY, and temporary\n *\n * This repo has run this migration once already — AGL-237 moved datasets,\n * contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the\n * fallback — and `orgDataCollectionForHost` carries what it learned:\n *\n * \"a second storage path that can still be WRITTEN is a second boundary to\n * enforce forever, which undoes the premise of scoped sharing: one home\n * per resource plus an explicit scope.\"\n *\n * So the host path is never written here. It is read, once, to carry a\n * not-yet-migrated lead onto the org as a side effect of the next write that\n * touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill\n * has left to fold, and means no read can be answered by a stale host row\n * after a write has moved on. AGL-3277 deletes the fallback and the rules\n * block behind it once that backfill reports nothing left to plan.\n */\n\n\n/**\n * The org collection a lead is written to. Always the org — see the\n * read-only note above.\n */\nexport async function orgLeadsForHost(\n hostId: string,\n): Promise<FirebaseFirestore.CollectionReference> {\n return orgDataCollectionForHost(hostId, 'leads')\n}\n\n/**\n * The leads one site may LIST, narrowed by `visibleTo`.\n *\n * The Admin SDK does not evaluate rules, so an unnarrowed read here would\n * serve one agency client's leads on another's site — the same reason\n * `scopedToHost` exists for every other org-owned collection.\n */\nexport async function orgLeadsQueryForHost(hostId: string): Promise<{\n ref: FirebaseFirestore.CollectionReference\n query: FirebaseFirestore.Query\n}> {\n const ref = await orgLeadsForHost(hostId)\n return { ref, query: scopedToHost(ref, hostId) }\n}\n\n/**\n * The tokens a reader holding this group may ask `array-contains-any` for.\n * Capped at 30 by `crmReadTokens`, as the contacts list is.\n */\nexport async function leadReadTokensForHost(\n hostId: string,\n group?: ConsentGroup,\n): Promise<ScopeToken[]> {\n return crmReadTokens(group ?? (await consentGroupForSite(hostId)))\n}\n\n/**\n * The `visibleTo` a capture on this site stamps: the consent group's sites,\n * or `['org']` where the org set `defaultResourceScope`.\n *\n * Widened by the capture, never by the lookup — a site that has never\n * captured this person gains nothing by finding them, which is what keeps an\n * agency's clients apart on a record they share. Callers UPDATING an\n * existing lead union these in rather than replacing, exactly as\n * `upsert-contact` does.\n */\nexport async function leadScopeForHost(\n hostId: string,\n org?: Record<string, unknown> | null,\n): Promise<ScopeToken[]> {\n const group = await consentGroupForSite(hostId, org)\n return crmScopeTokens(org ?? null, group)\n}\n\n/**\n * The lead for an address, wherever it currently lives.\n *\n * ## The lookup is UNSCOPED, and has to be\n *\n * One human who touched two sibling brands is one person. Narrowing this to\n * what the capturing site may already see would let a second submission on a\n * sibling brand mint a SECOND record for the same address — the duplication\n * this migration exists to end, reintroduced one layer up. Recognizing\n * somebody and being allowed to read their row are different acts:\n * this finds the person, {@link leadScopeForHost} decides who may see them.\n *\n * `upsert-contact` states the same rule at its own dedupe lookup, and for the\n * same reason. A caller that wants only what a site may SEE wants\n * {@link orgLeadsQueryForHost}.\n *\n * @returns the org row; else the not-yet-migrated host row; else `null`.\n */\nexport async function readLeadForHost(\n hostId: string,\n key: string,\n): Promise<FirebaseFirestore.DocumentSnapshot | null> {\n const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get()\n return orgRow.exists ? orgRow : null\n}\n\n/**\n * The ref a write should target.\n *\n * This carried a not-yet-backfilled host row onto the org first, and reported\n * whether it had (`carried`). AGL-3276 emptied the host path and AGL-3277\n * removed it, so there is nothing left to carry: the org row is the only row.\n * `carried` stays on the shape, always `false`, because several callers\n * destructure it and a lie is cheaper to read than a signature change that\n * says nothing.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n return { ref, existed: (await ref.get()).exists, carried: false }\n}\n\n\n/**\n * Make a tripped ceiling OBSERVABLE rather than a silent drop — the * standing rule that a control which is not visible in the console does not\n * count as shipped.\n *\n * Two audiences, one call, in exactly `recordAbuseCeilingTrip`'s shape\n * (`apps/tenant/app/api/forms/submit/route.ts`, AGL-1655):\n *\n * - A durable per-month refusal count at\n * `hosts/{id}/counters/{siteMembers|leads}Refused`. Counters are excluded\n * from every client write in `cloud/firebase-firestore.rules` (AGL-1367),\n * so the record cannot be edited away by the site it describes, and host\n * admins can already READ it — which is what lets the inbox console page\n * render it with no rules change.\n * - One in-app notification to the site's managers, on the FIRST refusal of\n * the month only. A notification per refused bot request would be the\n * flood again, delivered.\n *\n * The counter is month-keyed even though the CEILING is a total, because the\n * two facts are different: the ceiling is \"how many records exist\" and this is\n * \"how many were turned away recently\". Only the second is worth a notice.\n *\n * Best-effort throughout: bookkeeping that failed must never turn a contained\n * refusal into a 500, because a 500 is an invitation to retry.\n */\nexport async function recordVisitorRecordCeilingTrip(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n kind: VisitorRecordKind\n ceiling: number\n monthKey?: string\n}): Promise<void> {\n const { hostRef, hostId, kind, ceiling } = options\n const monthKey = options.monthKey ?? submissionMonthKey()\n try {\n const refusedRef = hostRef\n .collection('counters')\n .doc(visitorRecordRefusedCounterId(kind))\n const refusedSnapshot = await refusedRef.get()\n const alreadyRefused = Number(refusedSnapshot.get(monthKey) ?? 0)\n await refusedRef.set(\n {\n [monthKey]: FieldValue.increment(1),\n // Explicit values only — Firestore rejects `undefined`.\n ceiling,\n lastRefusedAtMs: Date.now(),\n },\n { merge: true },\n )\n if (alreadyRefused === 0) {\n const leads = kind === 'leads'\n await notifyHostManagers(hostId, {\n type: 'system.visitorRecordsPaused',\n title: leads\n ? 'Lead capture paused — this site is at the platform limit'\n : 'Sign-ups paused — this site is at the platform limit',\n body:\n `This site holds ${ceiling.toLocaleString()} ` +\n `${leads ? 'leads' : 'member accounts'}, which is the platform ` +\n `safety limit, so further ${leads ? 'leads' : 'sign-ups'} are ` +\n 'being refused. This is not part of your plan — every plan ' +\n `includes unlimited ${leads ? 'leads' : 'member accounts'}. ` +\n `Remove some ${leads ? 'leads' : 'members'}, or contact support ` +\n 'if this is real traffic.',\n link: `/${hostId}/inbox`,\n })\n }\n } catch (error) {\n console.error('visitor record ceiling bookkeeping failed', error)\n }\n}\n\n/** What a caller wants stored on the lead. `createdAt` is stamped here. */\nexport interface HostLeadInput {\n email: string\n /** The name the person typed, when they typed one (AGL-2303). */\n name?: string\n /** `signup`, `booking`, `form:{formId}` — the surface that produced it. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` already carries (AGL-301). Omitted or `false` writes\n * nothing: a lead is a side effect of an action the visitor DID take\n * (sign up, book), which is not by itself consent to be emailed\n * marketing, so this is only set when the caller captured an explicit\n * checkbox.\n */\n marketingConsent?: boolean\n /**\n * The `consentGroupDisclosureKey` the capture surface rendered beside that\n * checkbox (AGL-3320). The grant pools across the site's consent group only\n * when it is the group's current key, and is this site's alone otherwise —\n * the same rule `upsertHostContact` applies.\n */\n disclosedConsentGroup?: string | null\n}\n\n/**\n * Record one lead at `hosts/{hostId}/leads/{personKey}`, bounded by\n * `LEADS_MAX_PER_HOST` (AGL-1529).\n *\n * ## One person is one document\n *\n * `docs/specs/reusable-forms.md` §4b. This used to be `tx.create(ref.doc())`\n * — an auto-id per capture event — so one returning customer who signed up\n * and booked twice was three \"leads\". The Members & leads tab presented a\n * list of events as a list of people, and the only thing holding two rows for\n * one person together was string equality on the address at render time.\n *\n * The events are not lost, they are just no longer the record: `sources`\n * carries every surface that produced a capture, `submissionCount` counts\n * them, and `firstSeenAtMs`/`lastSeenAtMs` bracket them. The submissions, the\n * bookings and the member document are still there and still one row each.\n *\n * The id is {@link personKey} — `sha256(normalizeContactEmail(email))`, the\n * SAME derivation a list membership's `memberKey` uses, imported rather than\n * restated. Two specs named this function and the rule both stated is that\n * whichever ships second imports the first's helper: a second copy is how\n * `emailSuppressionKey` and `suppressionId` came to disagree.\n *\n * ## Why every lead writer goes through here\n *\n * There are three lead writes in the repo — the sign-up handler and the two\n * bookings paths — and all three were `hostRef.collection('leads').add(…)`\n * with a `.catch()` on the end. A cap enforced at two of three call sites is\n * not a cap, and the fourth writer somebody adds next month would not have\n * one either. One function is the only shape that survives that.\n *\n * ## The count is evaluated INSIDE the transaction that writes\n *\n * A create-time quota can be laundered by WHEN it is evaluated, not by the\n * counting rule (AGL-2231/2265/2266). Read-then-decide-then-`add()` lets N\n * concurrent visitors each read the same pre-count, each find room, and each\n * land — and nothing re-counts afterwards, so the extra rows are permanent.\n * `Transaction.get(AggregateQuery)` serialises the count against a concurrent\n * create into the same collection: the loser retries, re-reads the higher\n * count, and is refused. The count is of LIVE documents, so triaging leads in\n * the inbox frees the slots.\n *\n * ## Refusing a lead never fails the visitor's action\n *\n * A lead is a SIDE EFFECT — of a sign-up, or of a booking. The visitor did\n * not ask for it and cannot see it, so refusing one must not refuse the thing\n * they did ask for. This returns a boolean and never throws; the trip is\n * recorded and the caller carries on. That is the honest split, and it is why\n * `SITE_MEMBERS_MAX_PER_HOST` (which governs an action a visitor DID take) is\n * enforced with a 429 in the sign-up handler and this is not.\n */\nexport async function addHostLead(options: {\n hostRef: FirebaseFirestore.DocumentReference\n hostId: string\n lead: HostLeadInput\n /**\n * The ceiling to compare against. Defaults to `LEADS_MAX_PER_HOST` and is\n * passed by NOTHING in production — it exists so the suite can re-drive the\n * same count against a ceiling one higher and require the write to succeed.\n * A refusal that survives its own ceiling being raised was never that\n * ceiling's refusal, and there is exactly ONE comparison below, so knifing\n * it cannot be absorbed by a fallback branch.\n */\n ceiling?: number\n /**\n * The campaign this person came from, already resolved by the door.\n *\n * Resolved rather than raw, and passed rather than looked up, because one\n * visitor action reaches several writers: a form submission that creates a\n * submission, a contact AND a lead must pay for the touch lookup once. A\n * door that hands none — every order path, every import — attributes\n * nothing, which is how a lead that no campaign caused stays uncredited.\n */\n touch?: ResolvedCampaignTouch | null\n}): Promise<boolean> {\n const { hostRef, hostId, lead } = options\n const maxPerHost = options.ceiling ?? LEADS_MAX_PER_HOST\n try {\n const leadsRef = await orgLeadsForHost(hostId)\n const firestore = hostRef.firestore\n /*\n * `null` for anything that is not a usable address — a lead captured\n * against a malformed one keeps an auto-id and stays its own row. Keying\n * several unusable addresses under one guessed id would merge two\n * different people, which is worse than two rows for one.\n */\n const key = personKey(lead.email)\n /*\n * A lead the org has not taken over yet is carried across BEFORE the\n * transaction opens (AGL-3275). It has to happen outside: the carry is\n * itself a read-then-write, and a transaction that also counted the\n * collection would be reading a row it was about to create. Two doors\n * carrying the same person in the same second both write the legacy\n * fields under `merge`, so the race is a no-op rather than a conflict.\n */\n const leadRef = key\n ? (await leadForWrite(hostId, key)).ref\n : leadsRef.doc()\n /*\n * The scope this capture stamps, and the group whose terms its consent is\n * recorded under. Both resolved HERE rather than in the transaction body:\n * a contended transaction re-runs that body, and neither of these can\n * change between attempts, so resolving them inside would pay for the org\n * read again on every retry.\n */\n const scope = await leadScopeForHost(hostId)\n const group = await consentGroupForSite(hostId)\n const now = Date.now()\n const seen = {\n // `arrayUnion`, so a person who books twice has `['booking']` and one\n // who signed up and then submitted a form has both. Bounded by the\n // number of surfaces, not by the number of captures.\n sources: FieldValue.arrayUnion(lead.source),\n lastSeenAtMs: now,\n submissionCount: FieldValue.increment(1),\n ...(lead.name ? { name: lead.name } : {}),\n }\n let created = false\n const refused = await firestore.runTransaction(async (tx) => {\n // Reset per attempt: a contended transaction re-runs its body, and a\n // flag left standing from an aborted attempt would credit a campaign\n // with a person who turned out to exist already.\n created = false\n // ALL READS BEFORE THE WRITE, which Firestore requires.\n const existing = await tx.get(leadRef)\n /*\n * ⛔ THE CEILING GATES A NEW PERSON, NEVER AN EXISTING ONE.\n *\n * A returning visitor's capture is an UPDATE — it does not grow the\n * collection, so refusing it buys no capacity and costs the customer\n * the source and the timestamp they would have learned. That is the\n * enforcement-at-use shape the capacity rule exists to forbid: a limit\n * must refuse the addition, never a person already recorded or the\n * data attached to them.\n *\n * It also means the count is only paid on a genuinely new person,\n * which is the case that can move it.\n */\n if (!existing.exists) {\n /*\n * COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).\n *\n * The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a\n * per-SITE ceiling. An unfiltered count would charge every brand in\n * an agency's account for every other brand's leads, and the first\n * client to fill its allowance would refuse captures on sites that\n * had taken none. `scopedToHost` is the same narrowing every other\n * org-owned read uses, so a single-site org counts exactly what it\n * counted before this moved.\n *\n * A lead shared by two brands in one consent group is counted by\n * both, which is the honest answer: each of them holds it.\n */\n const used = (\n await tx.get(scopedToHost(leadsRef, hostId).count())\n ).data().count\n if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true\n }\n /*\n * Consent is carried forward and never cleared.\n *\n * A basis is written only when this capture carried an explicit\n * opt-in, so a later booking by someone who did not tick the box\n * leaves an earlier grant standing — absent-or-granted, the shape\n * every other writer uses. The TIMESTAMP is carried over rather than\n * restamped, for the reason given at the read below.\n */\n /*\n * The EARLIEST grant is the one that happened, so a later capture\n * carrying the same checkbox keeps the original date rather than\n * restamping when this person opted in. Read back through the shared\n * reader so \"already consented\" means the same thing here as it does\n * at send time.\n */\n /*\n * THE SITE'S REAL GROUP, because the silo that justified a group of one\n * is gone (AGL-3275).\n *\n * This read `soloConsentGroup(hostId)` for as long as a lead lived at\n * `hosts/{hostId}/leads`, and the reason was the path: private by\n * construction, so pooling a lead's basis would have recorded a\n * disclosure that reached nothing, while the contact written by the\n * same capture door was org-shared and pooled.\n *\n * A lead is now org-shared on exactly the terms the contact is, so the\n * premise is false and keeping the group of one would leave a\n * multi-brand org holding a lead its sibling brand can SEE but may not\n * MAIL — a narrower basis than the one `consentGroupDisclosure` named\n * beside the checkbox this person ticked. Pooling here records what\n * they were actually told, and only that: the grant reaches the group\n * when the capture surface sent back the group's current disclosure\n * key (AGL-3320), and this site alone otherwise. An undeclared group is\n * still a group of one, so an agency is unchanged and configures\n * nothing.\n *\n * Resolved above the transaction — see `group`'s declaration.\n */\n const prior = readMarketingBasis(existing.data() ?? null, group)\n const consentAtMs =\n prior.basis === 'granted' && prior.basisAtMs !== null\n ? prior.basisAtMs\n : now\n created = !existing.exists\n tx.set(\n leadRef,\n {\n email: lead.email,\n ...seen,\n /*\n * WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).\n *\n * `arrayUnion` rather than a replace, exactly as `upsert-contact`\n * stamps a contact: this site just collected this person, so it may\n * see the row. A site that merely FOUND them — the unscoped dedupe\n * lookup in `readLeadForHost` — gains nothing, which is what keeps\n * an agency's clients apart on a record they share.\n */\n visibleTo: FieldValue.arrayUnion(...scope),\n /*\n * EVERY SITE THAT CAPTURED THIS PERSON, not just the first\n * (AGL-3275).\n *\n * This was written once, on create, and that was sound while the\n * collection sat under one host and could hold only that host's\n * name. On the org the row is shared, so a sibling brand capturing\n * a person the first brand already holds has to be recorded here or\n * the \"Known by\" answer silently omits it — the same `arrayUnion`\n * the contact door has always used for this field, now that a lead\n * has the same question to answer.\n */\n [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId),\n ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n }),\n /*\n * The basis is recorded under the GROUP, keyed by its host, because\n * {@link readMarketingBasis} is one function over four silos and a\n * silo whose basis lived somewhere else would need the reader to\n * know which collection it was handed. A reader that has to be told\n * the shape is a reader that can be told the wrong one.\n */\n ...(lead.marketingConsent\n ? marketingConsentFieldsForGroup(\n consentGroupForGrant(group, lead.disclosedConsentGroup),\n consentAtMs,\n )\n : {}),\n },\n { merge: true },\n )\n return false\n })\n if (refused) {\n await recordVisitorRecordCeilingTrip({\n hostRef,\n hostId,\n kind: 'leads',\n ceiling: maxPerHost,\n })\n return false\n }\n /*\n * ATTRIBUTED ON CREATION ONLY.\n *\n * A returning visitor's capture is an update — the campaign did not\n * produce a lead, it produced another visit by a person the site already\n * held — and crediting it would let whichever campaign ran most recently\n * re-earn every lead on the list. `created` is set inside the transaction\n * that decides it, so the attribution and the write agree about whether\n * this person is new.\n *\n * Awaited rather than fired off: `addHostLead` already returns only after\n * its own write, and a caller that `void`s it (every one of them) is\n * unaffected. Never throws, so a failure here cannot cost the lead.\n */\n if (created && options.touch) {\n await attributeCampaignConversion({\n hostId,\n kind: 'lead',\n refId: leadRef.id,\n touch: options.touch,\n convertedAtMs: now,\n })\n }\n return true\n } catch (error) {\n // Same posture the three original call sites had (`.catch(() => undefined)`\n // / `.catch(console.error)`): a lead that failed to store must not fail\n // the sign-up or the booking that produced it.\n console.error('lead write failed', error)\n return false\n }\n}\n"],"names":["CAPTURED_BY_HOST_FIELD","checkVisitorRecordCeiling","LEADS_MAX_PER_HOST","marketingConsentFieldsForGroup","personKey","readMarketingBasis","submissionMonthKey","visitorRecordRefusedCounterId","FieldValue","attributeCampaignConversion","notifyHostManagers","firebaseAdmin","consentGroupForSite","orgDataCollectionForHost","scopedToHost","crmReadTokens","crmScopeTokens","consentGroupForGrant","firestore","app","orgLeadsForHost","hostId","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","doc","get","exists","leadForWrite","existed","carried","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedSnapshot","refusedRef","collection","alreadyRefused","Number","set","increment","lastRefusedAtMs","Date","now","merge","leads","type","title","body","toLocaleString","link","error","console","addHostLead","lead","maxPerHost","leadsRef","email","leadRef","scope","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","refused","runTransaction","tx","existing","used","count","data","exceeded","prior","consentAtMs","basis","basisAtMs","visibleTo","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","disclosedConsentGroup","touch","refId","id","convertedAtMs"],"mappings":"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"}
@@ -132,6 +132,29 @@ export interface EnrollListMemberInput {
132
132
  * anything is written, so there is a single writer of the consent fields.
133
133
  */
134
134
  consent?: ListMemberConsent;
135
+ /**
136
+ * The person's own grants, as `grantEntriesAsRecorded` read them off their
137
+ * record — what a PASS-THROUGH carries (AGL-3320).
138
+ *
139
+ * A `contact-opt-in` consent with these writes exactly these sites, with
140
+ * each grant's own moment and the disclosure it was given under: an opt-in
141
+ * given to one site before the group existed stays that site's on the list,
142
+ * and one given under the group's sentence reaches the sites it named.
143
+ * Re-recording the pass-through over {@link group} instead would widen the
144
+ * first to every site the group names today.
145
+ */
146
+ grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
147
+ /**
148
+ * The `consentGroupDisclosureKey` a capture surface rendered beside its
149
+ * checkbox, for a {@link marketingConsent} captured just now. The grant
150
+ * pools across {@link group} only when it is the group's current key.
151
+ *
152
+ * Every other basis without {@link grantEntries} — an attestation, a
153
+ * capture that rendered no disclosure — is recorded for the enrolling site
154
+ * alone: an operator's word about one site's list is not a disclosure
155
+ * anybody read about the rest.
156
+ */
157
+ disclosedConsentGroup?: string | null;
135
158
  /**
136
159
  * How the person got here: enrolled by hand or by an automation
137
160
  * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).
@@ -35,7 +35,8 @@
35
35
  * no caller is offered the chance to derive its own.
36
36
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
37
37
  import { FieldValue } from "firebase-admin/firestore";
38
- import { normalizeContactEmail, personKey, CAPTURED_BY_HOST_FIELD, marketingConsentFieldsForGroup, readMarketingBasis } from "@aglyn/aglyn/server";
38
+ import { normalizeContactEmail, personKey, CAPTURED_BY_HOST_FIELD, MARKETING_CONSENT_BASIS_FIELD, MARKETING_CONSENT_BY_HOST_FIELD, MARKETING_CONSENT_FIELD, marketingConsentFieldsForGroup, readMarketingBasis } from "@aglyn/aglyn/server";
39
+ import { consentGroupForGrant } from "@aglyn/aglyn/app-utils/consent-groups";
39
40
  import { createHash, createHmac } from "node:crypto";
40
41
  /**
41
42
  * The two ids this collection was written under before `personKey`.
@@ -106,7 +107,7 @@ import { createHash, createHmac } from "node:crypto";
106
107
  * a caller that can see one is expected to consult it, and the send-time
107
108
  * consent join reads the row this function writes either way.
108
109
  */ export async function enrollListMember(input) {
109
- var _ref, _input_consent, _consent_byUid, _consent_reason;
110
+ var _ref, _input_consent;
110
111
  const email = normalizeContactEmail(input.email);
111
112
  if (!email) return {
112
113
  enrolled: false,
@@ -153,11 +154,7 @@ import { createHash, createHmac } from "node:crypto";
153
154
  source: input.source
154
155
  }, input.via ? {
155
156
  via: input.via
156
- } : {}, consent ? marketingConsentFieldsForGroup(input.group, consent.atMs, {
157
- marketingConsentBasis: consent.basis,
158
- marketingConsentByUid: (_consent_byUid = consent.byUid) != null ? _consent_byUid : null,
159
- marketingConsentReason: (_consent_reason = consent.reason) != null ? _consent_reason : ''
160
- }) : {}, existing ? {} : {
157
+ } : {}, consent ? membershipConsentFields(input, consent) : {}, existing ? {} : {
161
158
  addedAt: FieldValue.serverTimestamp(),
162
159
  [CAPTURED_BY_HOST_FIELD]: [
163
160
  input.group.hostId
@@ -172,5 +169,50 @@ import { createHash, createHmac } from "node:crypto";
172
169
  created: !existing
173
170
  };
174
171
  }
172
+ /**
173
+ * The consent fields one enrollment writes: the basis, its attribution, and
174
+ * the sites it covers (AGL-3320).
175
+ *
176
+ * Three ways in, and the sites follow from which one it is:
177
+ *
178
+ * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}
179
+ * writes those sites and no others, each grant with its own moment and
180
+ * the disclosure it was given under;
181
+ * - a capture's checkbox pools over the group only when its surface sent
182
+ * back the group's current disclosure key — `consentGroupForGrant`;
183
+ * - everything else, an attestation among them, is the enrolling site's
184
+ * alone.
185
+ *
186
+ * The attribution is the membership's own on every entry, the pass-through's
187
+ * included, for the reason the write explains: an entry that kept an earlier
188
+ * attestation's account under a person's own opt-in would be a false claim
189
+ * about who vouched for them.
190
+ */ function membershipConsentFields(input, consent) {
191
+ var _consent_byUid, _consent_reason, _input_grantEntries;
192
+ const attribution = {
193
+ [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,
194
+ marketingConsentByUid: (_consent_byUid = consent.byUid) != null ? _consent_byUid : null,
195
+ marketingConsentReason: (_consent_reason = consent.reason) != null ? _consent_reason : ''
196
+ };
197
+ const carried = consent.basis === 'contact-opt-in' ? Object.entries((_input_grantEntries = input.grantEntries) != null ? _input_grantEntries : {}) : [];
198
+ if (carried.length) {
199
+ return {
200
+ [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(carried.map(([hostId, entry])=>{
201
+ var _entry_marketingConsentAtMs;
202
+ return [
203
+ hostId,
204
+ _extends({}, entry, {
205
+ [MARKETING_CONSENT_FIELD]: true,
206
+ // The grant's own moment; the pass-through's only when the
207
+ // person's record never stamped one, as the basis itself falls
208
+ // back to now.
209
+ marketingConsentAtMs: (_entry_marketingConsentAtMs = entry['marketingConsentAtMs']) != null ? _entry_marketingConsentAtMs : consent.atMs
210
+ }, attribution)
211
+ ];
212
+ }))
213
+ };
214
+ }
215
+ return marketingConsentFieldsForGroup(consentGroupForGrant(input.group, input.disclosedConsentGroup), consent.atMs, attribution);
216
+ }
175
217
 
176
218
  //# sourceMappingURL=list-members.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { createHash, createHmac } from 'node:crypto'\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n source: input.source,\n ...(input.via ? { via: input.via } : {}),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n */\n ...(consent\n ? marketingConsentFieldsForGroup(input.group, consent.atMs, {\n marketingConsentBasis: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n })\n : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","createHash","createHmac","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","email","normalized","key","filter","id","enrollListMember","input","consent","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","marketingConsent","atMs","Date","now","set","name","source","via","marketingConsentBasis","marketingConsentByUid","byUid","marketingConsentReason","reason","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC;AAGD,SAASA,UAAU,QAAQ,2BAA0B;AACrD,SACEC,qBAAqB,EACrBC,SAAS,EACTC,sBAAsB,EACtBC,8BAA8B,EAC9BC,kBAAkB,QAEb,sBAAqB;AAC5B,SAASC,UAAU,EAAEC,UAAU,QAAQ,cAAa;AAEpD;;;;;;;;;;;CAWC,GACD,SAASC,oBAAoBC,eAAuB;IAClD,OAAO;QACLH,WAAW,UAAUI,MAAM,CAACD,iBAAiBE,MAAM,CAAC;QACpDJ,WAAW,UAAU,qBAClBG,MAAM,CAACD,iBACPE,MAAM,CAAC,OACPC,KAAK,CAAC,GAAG;KACb;AACH;AAEA;;;;;;;CAOC,GACD,OAAO,SAASC,iBAAiBC,KAAc;IAC7C,MAAMC,aAAad,sBAAsBa;IACzC,IAAI,CAACC,YAAY,OAAO,EAAE;IAC1B,MAAMC,MAAMd,UAAUa;IACtB,IAAI,CAACC,KAAK,OAAO,EAAE;IACnB,OAAO;QAACA;WAAQR,oBAAoBO,YAAYE,MAAM,CAAC,CAACC,KAAOA,OAAOF;KAAK;AAC7E;AAoIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GACD,OAAO,eAAeG,iBACpBC,KAA4B;cA0C1BA,gBAmD+BC,gBACCA;IA5FlC,MAAMP,QAAQb,sBAAsBmB,MAAMN,KAAK;IAC/C,IAAI,CAACA,OAAO,OAAO;QAAEQ,UAAU;QAAOC,SAAS;IAAmB;IAClE,MAAMP,MAAMd,UAAUY;IACtB,IAAI,CAACE,KAAK,OAAO;QAAEM,UAAU;QAAOC,SAAS;IAAmB;IAEhE,MAAMC,UAAUJ,MAAMK,OAAO,CAACC,UAAU,CAAC;IACzC,MAAMC,eAAeH,QAAQI,GAAG,CAACZ;IACjC,MAAMa,aAAarB,oBAAoBM,MACrC,2EAA2E;IAC3E,yEAAyE;IACzE,gCAAgC;KAC/BG,MAAM,CAAC,CAACC,KAAOA,OAAOF,KACtBc,GAAG,CAAC,CAACZ,KAAOM,QAAQI,GAAG,CAACV;IAE3B,MAAMa,YAAY,MAAMX,MAAMK,OAAO,CAACO,SAAS,CAACC,MAAM,CACpDN,iBACGE;IAEL,MAAMK,WAAWH,UAAUI,IAAI,CAAC,CAACC,WAAaA,SAASC,MAAM;IAC7D,MAAMC,iBAASJ,4BAAAA,SAAUK,GAAG,mBAAIZ;IAEhC,IACEO,YACA7B,mBAAmB6B,SAASM,IAAI,IAA+BpB,MAAMqB,KAAK,EACvEC,KAAK,KAAK,YACb;QACA,OAAO;YAAEpB,UAAU;YAAOC,SAAS;QAAW;IAChD;IAEA;;;;;;;;;GASC,GACD,MAAMF,WACJD,iBAAAA,MAAMC,OAAO,YAAbD,iBACCA,MAAMuB,gBAAgB,GACnB;QAAED,OAAO;QAAkBE,MAAMC,KAAKC,GAAG;IAAG,IAC5C;IAEN,MAAMR,OAAOS,GAAG,CACd;QACEjC;OACIM,MAAM4B,IAAI,GAAG;QAAEA,MAAM5B,MAAM4B,IAAI;IAAC,IAAI,CAAC;QACzCC,QAAQ7B,MAAM6B,MAAM;OAChB7B,MAAM8B,GAAG,GAAG;QAAEA,KAAK9B,MAAM8B,GAAG;IAAC,IAAI,CAAC,GAsClC7B,UACAjB,+BAA+BgB,MAAMqB,KAAK,EAAEpB,QAAQuB,IAAI,EAAE;QACxDO,uBAAuB9B,QAAQqB,KAAK;QACpCU,qBAAqB,GAAE/B,iBAAAA,QAAQgC,KAAK,YAAbhC,iBAAiB;QACxCiC,sBAAsB,GAAEjC,kBAAAA,QAAQkC,MAAM,YAAdlC,kBAAkB;IAC5C,KACA,CAAC,GACDa,WACA,CAAC,IACD;QACEsB,SAASxD,WAAWyD,eAAe;QACnC,CAACtD,uBAAuB,EAAE;YAACiB,MAAMqB,KAAK,CAACiB,MAAM;SAAC;IAChD,IAEN;QAAEC,OAAO;IAAK;IAGhB,OAAO;QACLrC,UAAU;QACVsC,UAAUtB,OAAOpB,EAAE;QACnB2C,SAASvB,OAAOpB,EAAE,KAAKF;QACvB8C,SAAS,CAAC5B;IACZ;AACF"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/list-members.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The only writer of `orgs/{orgId}/lists/{listId}/members` (AGL-2499).\n *\n * Two enrollment routes reach this collection — the commerce newsletter\n * handler and the workflow `enrollList` step — and they used to derive the\n * document id two incompatible ways: a full `sha256(email)` on one side, a\n * `hmac('aglyn-list-member', email)` truncated to 20 hex on the other. The\n * same person subscribing by both routes became two members of one list.\n *\n * The id is now `personKey` on both, which is the derivation both\n * `docs/specs/email-overhaul.md` §3d and `docs/specs/reusable-forms.md` §4\n * specify, and it normalizes before hashing so casing cannot fork it either.\n *\n * ## Why a helper and not two corrected call sites\n *\n * Two call sites that merely agree today are what produced the split: nothing\n * about either one said the other existed. A third route — the reusable-forms\n * capture path is already specified — would have been written the same way.\n * Enrolling goes through this function so that the id has one definition and\n * no caller is offered the chance to derive its own.\n */\n\nimport type { DocumentReference } from 'firebase-admin/firestore'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n normalizeContactEmail,\n personKey,\n CAPTURED_BY_HOST_FIELD,\n MARKETING_CONSENT_BASIS_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n marketingConsentFieldsForGroup,\n readMarketingBasis,\n type ConsentGroup,\n} from '@aglyn/aglyn/server'\nimport { consentGroupForGrant } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { createHash, createHmac } from 'node:crypto'\n\n/**\n * The two ids this collection was written under before `personKey`.\n *\n * Read-only, and never written for a new row: they exist so an address\n * already enrolled under a legacy id is *found* rather than duplicated. See\n * `enrollListMember` for why that lookup is the migration.\n *\n * Both take the normalized address, which is what the two original call sites\n * happened to pass — each lowercased at its own entry point before hashing, so\n * every row already on the collection was keyed from a lowercased address even\n * though neither derivation enforced it.\n */\nfunction legacyListMemberIds(normalizedEmail: string): string[] {\n return [\n createHash('sha256').update(normalizedEmail).digest('hex'),\n createHmac('sha256', 'aglyn-list-member')\n .update(normalizedEmail)\n .digest('hex')\n .slice(0, 20),\n ]\n}\n\n/**\n * Every document id one address may be filed under on a list's `members`:\n * the canonical key first, then the two ids earlier writers minted. A\n * reader that must find the person wherever they were enrolled — the\n * erasure sweep (AGL-2623) — asks for all of them with one `getAll`, the\n * same set `enrollListMember` reads before it writes, so a member enrolled\n * under an old id is neither missed nor duplicated.\n */\nexport function listMemberDocIds(email: unknown): string[] {\n const normalized = normalizeContactEmail(email)\n if (!normalized) return []\n const key = personKey(normalized)\n if (!key) return []\n return [key, ...legacyListMemberIds(normalized).filter((id) => id !== key)]\n}\n\n/**\n * What makes an enrollment mailable, and who is answerable for it.\n *\n * Two values and no third. They are not interchangeable and the difference is\n * the whole reason the basis is stored rather than implied:\n *\n * - `contact-opt-in` — the person ticked a box. The enrollment carries a\n * decision they made, and `atMs` is when THEY made it, not when the row was\n * written. Nobody is answerable because nobody asserted anything.\n * - `operator-attested` — a console account stated that they have this\n * person's permission. That statement is itself the basis, so it is only\n * worth storing with the account that made it and the moment they did.\n *\n * Rendering the second as though it were the first is what a support or\n * compliance question cannot recover from, which is why the basis rides\n * beside `marketingConsent` on every row that has one.\n */\nexport type ListMemberConsentBasis = 'contact-opt-in' | 'operator-attested'\n\n/** A basis, with the attribution that makes it answerable. */\nexport interface ListMemberConsent {\n basis: ListMemberConsentBasis\n /**\n * When the basis was recorded.\n *\n * For a pass-through this is the ORIGINAL opt-in moment carried over from\n * the person's own record, because the question a consent record answers is\n * when the person said yes — not when somebody copied their answer onto a\n * list.\n */\n atMs: number\n /** The console account that attested. Meaningless for a pass-through. */\n byUid?: string | null\n /**\n * Why, in the operator's own terms, for whoever audits this later.\n *\n * Written for an attestation and ignored for a pass-through, which needs no\n * explanation beyond the person's own record. An import fills it from what\n * the FILE declared — the opt-in source and date the merchant supplied per\n * row — because \"a declared basis per address, not one checkbox over the\n * file\" is only worth asking for if the declaration survives onto the row\n * it was made about.\n */\n reason?: string\n}\n\nexport interface EnrollListMemberInput {\n /** `orgs/{orgId}/lists/{listId}` — the caller has already proved it exists. */\n listRef: DocumentReference\n /**\n * The consent group the enrollment is being made IN THE NAME OF.\n *\n * Lists live on the org and every site in it can mail one, so a membership\n * carries a basis that no path could attribute to a controller. A GROUP\n * rather than a site, because a business running three sites under one name\n * legitimately enrolls somebody into all three at once — and an agency's\n * client, which declared no group, gets exactly itself.\n *\n * Required and undefaulted: a caller that has not resolved a group has not\n * decided who this person agreed to hear from.\n */\n group: ConsentGroup\n email: string\n name?: string\n /** Free-form provenance: `'newsletter'`, `'action:{actionId}'`. */\n source: string\n /**\n * Explicit marketing opt-in, with a consent timestamp — the same shape\n * `upsertHostContact` and `addHostLead` already carry.\n *\n * A list membership had NO consent field of any kind, so `audience: 'list'`\n * had nothing for the send-time join to read\n * (`docs/specs/email-overhaul.md` §1d/§3f). This is the field that closes\n * it, and it is the caller's captured checkbox: enrolling is an act, and\n * `false`/omitted therefore writes nothing rather than a basis.\n */\n marketingConsent?: boolean\n /**\n * The basis in full, for a caller that knows more than \"a box was ticked\".\n *\n * `marketingConsent: true` is the shorthand a capture surface uses: it means\n * `contact-opt-in`, recorded now, asserted by nobody. Anything else — a\n * pass-through carrying the person's ORIGINAL opt-in date, or an\n * attestation carrying the account that made it — has to say so, and this\n * is where it says it. The two inputs are normalized to one value before\n * anything is written, so there is a single writer of the consent fields.\n */\n consent?: ListMemberConsent\n /**\n * The person's own grants, as `grantEntriesAsRecorded` read them off their\n * record — what a PASS-THROUGH carries (AGL-3320).\n *\n * A `contact-opt-in` consent with these writes exactly these sites, with\n * each grant's own moment and the disclosure it was given under: an opt-in\n * given to one site before the group existed stays that site's on the list,\n * and one given under the group's sentence reaches the sites it named.\n * Re-recording the pass-through over {@link group} instead would widen the\n * first to every site the group names today.\n */\n grantEntries?: Readonly<Record<string, Readonly<Record<string, unknown>>>>\n /**\n * The `consentGroupDisclosureKey` a capture surface rendered beside its\n * checkbox, for a {@link marketingConsent} captured just now. The grant\n * pools across {@link group} only when it is the group's current key.\n *\n * Every other basis without {@link grantEntries} — an attestation, a\n * capture that rendered no disclosure — is recorded for the enrolling site\n * alone: an operator's word about one site's list is not a disclosure\n * anybody read about the rest.\n */\n disclosedConsentGroup?: string | null\n /**\n * How the person got here: enrolled by hand or by an automation\n * (`'manual'`), or selected by a dynamic list's rule (`'rule'`).\n *\n * The materializer needs to tell its own rows apart from a manual one on\n * the same list, because a person who stops matching the rule leaves and a\n * person somebody added by hand does not.\n */\n via?: 'manual' | 'rule'\n}\n\nexport interface EnrolledListMember {\n /** The document actually written — a legacy id when one was adopted. */\n memberId: string\n /** True when an existing legacy-keyed row was written instead of a new one. */\n adopted: boolean\n /** False when the row already existed under some id. */\n created: boolean\n}\n\n/** Why nothing was written. */\nexport type EnrollListMemberRefusal =\n /** Not an address this collection can key. Nothing to write, nothing to fix. */\n | 'unusable-address'\n /**\n * The person's membership records a REFUSAL. Not overridable by any caller\n * and not reported as a failure to fix, because the fix would be to ignore\n * it.\n */\n | 'declined'\n\n/**\n * Enrolled, or refused and why.\n *\n * A union rather than `EnrolledListMember | null`, because the two refusals\n * are not the same event and a caller that cannot tell them apart is a caller\n * that reports a person's recorded refusal as a malformed address.\n */\nexport type EnrollListMemberResult =\n | ({ enrolled: true } & EnrolledListMember)\n | { enrolled: false; refusal: EnrollListMemberRefusal }\n\n/**\n * Enrolls one address into a list, at one document per person.\n *\n * ## The legacy lookup is the migration\n *\n * Changing the derivation without one would strand every row written under the\n * old ids: the next enrollment of an address already on the list would key a\n * *new* document beside the old one, so a defect that produced duplicates only\n * when two routes met would start producing them on a single route. Rather\n * than rewrite those ids — a bulk operation that has to delete the row it\n * replaces, and deleting an enrollment destroys the consent record that says\n * the person asked to be there — the write resolves the person's existing\n * document and keeps using it.\n *\n * The cost is one `getAll` of three refs, on a human-triggered signup, in\n * place of the blind `set` this replaced. It is one round trip, not three.\n *\n * `addedAt` is stamped only when the document is created, so re-enrolling\n * keeps the date the person actually joined — the same \"earliest wins\" collapse\n * `docs/specs/email-overhaul.md` §3d asks a backfill to preserve.\n *\n * ## A recorded refusal stops every route through this function\n *\n * This is the only writer of the collection, so the refusal check belongs\n * here and not at the four call sites: a guard on the button that adds\n * somebody by hand leaves the newsletter handler, the workflow `enrollList`\n * step and the dynamic-list materializer free to put the same person back.\n * The membership snapshot is already in hand from the id resolution above, so\n * the check costs no read.\n *\n * It answers the question about the LIST ROW. A refusal recorded on the\n * person's CRM record is a different document this function does not fetch —\n * a caller that can see one is expected to consult it, and the send-time\n * consent join reads the row this function writes either way.\n */\nexport async function enrollListMember(\n input: EnrollListMemberInput,\n): Promise<EnrollListMemberResult> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { enrolled: false, refusal: 'unusable-address' }\n const key = personKey(email)\n if (!key) return { enrolled: false, refusal: 'unusable-address' }\n\n const members = input.listRef.collection('members')\n const canonicalRef = members.doc(key)\n const legacyRefs = legacyListMemberIds(email)\n // A legacy derivation that happens to agree with `personKey` — `sha256` of\n // the same normalized address does — must not be fetched twice: `getAll`\n // rejects duplicate references.\n .filter((id) => id !== key)\n .map((id) => members.doc(id))\n\n const snapshots = await input.listRef.firestore.getAll(\n canonicalRef,\n ...legacyRefs,\n )\n const existing = snapshots.find((snapshot) => snapshot.exists)\n const target = existing?.ref ?? canonicalRef\n\n if (\n existing &&\n readMarketingBasis(existing.data() as Record<string, unknown>, input.group)\n .basis === 'declined'\n ) {\n return { enrolled: false, refusal: 'declined' }\n }\n\n /*\n * ONE value from the two ways a caller may state a basis.\n *\n * `marketingConsent: true` is a capture surface saying \"a box was ticked\n * just now\", which is `contact-opt-in` recorded at this moment with nobody\n * asserting it. Normalizing here rather than branching at the write is what\n * keeps the consent fields to a single writer — two branches stamping\n * overlapping subsets of them is how a row comes to carry a basis with no\n * timestamp, or a timestamp with no basis.\n */\n const consent: ListMemberConsent | null =\n input.consent ??\n (input.marketingConsent\n ? { basis: 'contact-opt-in', atMs: Date.now() }\n : null)\n\n await target.set(\n {\n email,\n ...(input.name ? { name: input.name } : {}),\n source: input.source,\n ...(input.via ? { via: input.via } : {}),\n /*\n * Written only when the caller has a basis, and never unwritten.\n *\n * A merge that stamped `marketingConsent: false` on the omitted case\n * would erase a basis this person gave on an earlier enrollment, and a\n * withdrawn consent is not the same event as a re-enrollment that\n * happened to carry no checkbox. Withdrawal has its own path — the\n * unsubscribe link and the suppression list.\n *\n * `marketingConsentAtMs` moves with each fresh basis on purpose: the\n * question a consent record answers is when the person last said yes.\n *\n * The `marketingConsent` family rather than the `consent*` names\n * `docs/specs/email-overhaul.md` §3d sketched: `readMarketingBasis` is\n * the shipped reader and it reads these, so a second vocabulary here\n * would be a basis the send-time join cannot see. The two new fields\n * are attribution ON that field — they say why the person is mailable,\n * never whether — so nothing about the send decision moves into them.\n *\n * Written UNDER the enrolling GROUP, through the one writer helper. A\n * list lives on the org and every site in it can mail one, so a basis\n * written at the top of this row would enroll a person into one\n * client's newsletter and make them mailable by all of them. A group of\n * one — the default — is that client and nobody else.\n *\n * `marketingConsentByUid` is written on EVERY basis, `null` for a\n * pass-through. Leaving it absent would let a real opt-in that\n * supersedes an earlier attestation inherit the attesting account, and\n * a row reading \"this account vouched for them\" when the person ticked\n * a box themselves is a false attribution in the one direction a\n * compliance answer cannot afford.\n *\n * `marketingConsentReason` follows the same rule for the same reason:\n * always written, `''` for a pass-through, so a later real opt-in\n * cannot inherit the sentence an earlier import wrote about where the\n * merchant said the address came from.\n *\n * WHICH sites the basis covers is `membershipConsentFields`' to say,\n * and it never re-derives a grant from the group as it stands now.\n */\n ...(consent ? membershipConsentFields(input, consent) : {}),\n ...(existing\n ? {}\n : {\n addedAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [input.group.hostId],\n }),\n },\n { merge: true },\n )\n\n return {\n enrolled: true,\n memberId: target.id,\n adopted: target.id !== key,\n created: !existing,\n }\n}\n\n/**\n * The consent fields one enrollment writes: the basis, its attribution, and\n * the sites it covers (AGL-3320).\n *\n * Three ways in, and the sites follow from which one it is:\n *\n * - a PASS-THROUGH with the person's {@link EnrollListMemberInput.grantEntries}\n * writes those sites and no others, each grant with its own moment and\n * the disclosure it was given under;\n * - a capture's checkbox pools over the group only when its surface sent\n * back the group's current disclosure key — `consentGroupForGrant`;\n * - everything else, an attestation among them, is the enrolling site's\n * alone.\n *\n * The attribution is the membership's own on every entry, the pass-through's\n * included, for the reason the write explains: an entry that kept an earlier\n * attestation's account under a person's own opt-in would be a false claim\n * about who vouched for them.\n */\nfunction membershipConsentFields(\n input: EnrollListMemberInput,\n consent: ListMemberConsent,\n): Record<string, unknown> {\n const attribution = {\n [MARKETING_CONSENT_BASIS_FIELD]: consent.basis,\n marketingConsentByUid: consent.byUid ?? null,\n marketingConsentReason: consent.reason ?? '',\n }\n const carried =\n consent.basis === 'contact-opt-in'\n ? Object.entries(input.grantEntries ?? {})\n : []\n if (carried.length) {\n return {\n [MARKETING_CONSENT_BY_HOST_FIELD]: Object.fromEntries(\n carried.map(([hostId, entry]) => [\n hostId,\n {\n ...entry,\n [MARKETING_CONSENT_FIELD]: true,\n // The grant's own moment; the pass-through's only when the\n // person's record never stamped one, as the basis itself falls\n // back to now.\n marketingConsentAtMs: entry['marketingConsentAtMs'] ?? consent.atMs,\n ...attribution,\n },\n ]),\n ),\n }\n }\n return marketingConsentFieldsForGroup(\n consentGroupForGrant(input.group, input.disclosedConsentGroup),\n consent.atMs,\n attribution,\n )\n}\n"],"names":["FieldValue","normalizeContactEmail","personKey","CAPTURED_BY_HOST_FIELD","MARKETING_CONSENT_BASIS_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","marketingConsentFieldsForGroup","readMarketingBasis","consentGroupForGrant","createHash","createHmac","legacyListMemberIds","normalizedEmail","update","digest","slice","listMemberDocIds","email","normalized","key","filter","id","enrollListMember","input","enrolled","refusal","members","listRef","collection","canonicalRef","doc","legacyRefs","map","snapshots","firestore","getAll","existing","find","snapshot","exists","target","ref","data","group","basis","consent","marketingConsent","atMs","Date","now","set","name","source","via","membershipConsentFields","addedAt","serverTimestamp","hostId","merge","memberId","adopted","created","attribution","marketingConsentByUid","byUid","marketingConsentReason","reason","carried","Object","entries","grantEntries","length","fromEntries","entry","marketingConsentAtMs","disclosedConsentGroup"],"mappings":"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"}
@@ -126,7 +126,10 @@ const NOTIFY_EMAIL_MAX_SENDS = 50;
126
126
  const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD);
127
127
  const legacy = userDoc.get('notificationPrefs');
128
128
  if (notificationChannelEnabled(settings, 'console', payload.type, scope, legacy)) {
129
- batch.set(db.collection('users').doc(userDoc.id).collection('notifications').doc(), _extends({}, payload, {
129
+ batch.set(db.collection('users').doc(userDoc.id).collection('notifications').doc(), // `read: false` on create, so the feed can ask for unread and read
130
+ // alike by equality (AGL-3321; see `AglynNotification.read`).
131
+ _extends({}, payload, {
132
+ read: false,
130
133
  createdAt: FieldValue.serverTimestamp()
131
134
  }));
132
135
  count += 1;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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 buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const link = payload.link && origin ? `${origin}${payload.link}` : ''\n const body = [\n payload.title,\n payload.body ?? '',\n link,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n const result = await sendEmail({\n to: recipients,\n subject: payload.title,\n text: body,\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification about a workspace is that workspace's\n // mail, and a staff alert or an account-level notice is the platform's.\n await (payload.orgId ? meterOrgEmail(payload.orgId) : meterPlatformEmail())\n .catch(() => undefined)\n }\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n { ...payload, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n */\n if (\n mailTo.length &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(mailTo, payload, options.emails ?? {})\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","notificationChannelEnabled","Route","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","lookups","sent","uid","address","String","toLowerCase","includes","pooled","catch","record","email","recipients","length","link","body","title","filter","Boolean","join","result","to","subject","text","context","headers","orgId","undefined","notifyUsers","options","db","targets","Set","slice","userDocs","getAll","map","collection","doc","scope","hostId","batch","count","mailTo","userDoc","settings","get","legacy","type","set","id","createdAt","serverTimestamp","push","commit","has","emails","error","console","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","host","memberRoles","managers","Object","entries"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/notifications.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 buildRoute,\n NOTIFICATION_SELF_SENT_EMAIL_TYPES,\n NOTIFICATION_SETTINGS_FIELD,\n notificationChannelEnabled,\n type AglynNotification,\n Route,\n type NotificationSettings,\n} from '@aglyn/aglyn/server'\nimport { isEmailConfigured, sendEmail } from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findUserByUidAcrossPools, listStaffUidsAcrossPools } from './auth-pools'\nimport { filterSuppressedEmails } from './email-suppression'\nimport { meterOrgEmail, meterPlatformEmail } from './email-metering'\nimport firebaseAdmin from './firebase-admin'\nimport { listOrgMembers } from './organizations'\n\nconst firestore = () => firebaseAdmin.app().firestore()\n\nexport type NotificationPayload = Omit<\n AglynNotification,\n '$id' | 'createdAt' | 'readAt'\n>\n\nexport interface NotifyUsersOptions {\n /**\n * Addresses the caller already holds, by uid (AGL-3224).\n *\n * Purely an optimization, and one worth taking where it is free: a caller\n * that read the roster — `notifyOrgAdmins` does — has every address in hand\n * already, and without this the email half would look each one up again in\n * the directory. Absent uids fall back to that lookup, so passing a partial\n * map is fine and passing none is correct.\n */\n emails?: Readonly<Record<string, string | null | undefined>>\n}\n\n/** The console's absolute origin for an email link, or `''` when unset. */\nfunction consoleOrigin(): string {\n return (process.env.NEXT_PUBLIC_CONSOLE_URL ?? '').trim().replace(/\\/+$/, '')\n}\n\n/**\n * At most this many directory lookups per fan-out, and at most this many\n * notification emails.\n *\n * Both ceilings only ever bite on a fan-out where many recipients have\n * OPTED IN — the email channel defaults off, so the ordinary notification\n * costs exactly what it cost before this existed. They are here because a\n * fan-out takes up to 400 uids and `notifyUsers` runs inside the mutation\n * that emitted it: a notification must not be able to turn one write into\n * four hundred directory reads and four hundred sends.\n */\nconst NOTIFY_EMAIL_MAX_LOOKUPS = 25\nconst NOTIFY_EMAIL_MAX_SENDS = 50\n\n/**\n * The email beside the console notification (AGL-3224).\n *\n * One send per recipient, never a shared `to:` list — two people who manage\n * the same site have not agreed to be shown each other's addresses, and the\n * settings link in the footer belongs to one person.\n *\n * `List-Unsubscribe` points at the settings page rather than at a one-click\n * endpoint: this is not marketing mail and RFC 8058's POST form would be\n * claiming a capability that does not exist. What it does do is put the way\n * out in the headers as well as the body, so a client that surfaces it shows\n * the person the page where the switch they want actually is.\n *\n * Never throws, like everything else on this path.\n */\nasync function emailNotification(\n uids: string[],\n payload: NotificationPayload,\n known: Readonly<Record<string, string | null | undefined>>,\n): Promise<void> {\n const origin = consoleOrigin()\n const settingsUrl = `${origin}${buildRoute(Route.MANAGE_NOTIFICATION_SETTINGS)}`\n let lookups = 0\n let sent = 0\n for (const uid of uids) {\n if (sent >= NOTIFY_EMAIL_MAX_SENDS) break\n let address = String(known[uid] ?? '').trim().toLowerCase()\n if (!address.includes('@')) {\n if (lookups >= NOTIFY_EMAIL_MAX_LOOKUPS) continue\n lookups += 1\n const pooled = await findUserByUidAcrossPools(uid).catch(() => null)\n address = String(pooled?.record?.email ?? '').trim().toLowerCase()\n }\n if (!address.includes('@')) continue\n const recipients = await filterSuppressedEmails([address])\n if (!recipients.length) continue\n const link = payload.link && origin ? `${origin}${payload.link}` : ''\n const body = [\n payload.title,\n payload.body ?? '',\n link,\n origin ? `Change what you are emailed about: ${settingsUrl}` : '',\n ]\n .filter(Boolean)\n .join('\\n\\n')\n const result = await sendEmail({\n to: recipients,\n subject: payload.title,\n text: body,\n context: 'notification',\n ...(origin\n ? { headers: { 'List-Unsubscribe': `<${settingsUrl}>` } }\n : {}),\n })\n if (!result.sent) continue\n sent += 1\n // Whose cost it is: a notification about a workspace is that workspace's\n // mail, and a staff alert or an account-level notice is the platform's.\n await (payload.orgId ? meterOrgEmail(payload.orgId) : meterPlatformEmail())\n .catch(() => undefined)\n }\n}\n\n/**\n * Notification fan-out (AGL-259): batch-writes one doc per recipient at\n * `users/{uid}/notifications`, and — for recipients who asked for it\n * (AGL-3224) — sends one email beside it. Never throws: a notification miss,\n * or a send that failed, must not break the mutation that emitted it.\n */\nexport async function notifyUsers(\n uids: Iterable<string>,\n payload: NotificationPayload,\n options: NotifyUsersOptions = {},\n): Promise<void> {\n try {\n const db = firestore()\n const targets = [...new Set(uids)].filter(Boolean).slice(0, 400)\n if (!targets.length) return\n // Per-user preferences (AGL-267, AGL-3223): one getAll over the user\n // docs, which now answers for both channels and for all three scopes —\n // the settings live on this document precisely so that stays one read.\n const userDocs = await db.getAll(\n ...targets.map((uid) => db.collection('users').doc(uid)),\n )\n const scope = { orgId: payload.orgId, hostId: payload.hostId }\n const batch = db.batch()\n let count = 0\n const mailTo: string[] = []\n for (const userDoc of userDocs) {\n const settings = userDoc.get(NOTIFICATION_SETTINGS_FIELD) as\n | NotificationSettings\n | undefined\n const legacy = userDoc.get('notificationPrefs') as\n | Record<string, boolean>\n | undefined\n if (\n notificationChannelEnabled(\n settings,\n 'console',\n payload.type,\n scope,\n legacy,\n )\n ) {\n batch.set(\n db\n .collection('users')\n .doc(userDoc.id)\n .collection('notifications')\n .doc(),\n // `read: false` on create, so the feed can ask for unread and read\n // alike by equality (AGL-3321; see `AglynNotification.read`).\n { ...payload, read: false, createdAt: FieldValue.serverTimestamp() },\n )\n count += 1\n }\n if (\n notificationChannelEnabled(settings, 'email', payload.type, scope, legacy)\n ) {\n mailTo.push(userDoc.id)\n }\n }\n if (count > 0) await batch.commit()\n /*\n * THE EMAIL AFTER THE COMMIT, and not inside its `try`.\n *\n * The console notification is the durable record and the send is a\n * courtesy on top of it, so the order is the one where a failing network\n * call cannot lose the record. The two channels are independent by\n * design — a person may have muted the feed and asked for mail, or the\n * reverse — so neither is gated on the other having happened.\n *\n * Digests are excluded: they compose and send their own mail, under\n * their own switches, and the generic channel would send a second\n * message announcing that the first one had been sent.\n */\n if (\n mailTo.length &&\n isEmailConfigured() &&\n !NOTIFICATION_SELF_SENT_EMAIL_TYPES.has(payload.type)\n ) {\n await emailNotification(mailTo, payload, options.emails ?? {})\n }\n } catch (error) {\n console.error('notification fan-out failed', error)\n }\n}\n\n// Staff are identified by a Firebase Auth custom claim (`staff`), which is not\n// a Firestore query — so the roster comes from paginating the auth users. A\n// burst of ticket activity shouldn't rescan every time, so the (small) result\n// is cached briefly. Fails soft to an empty list.\nlet staffUidCache: { uids: string[]; at: number } | null = null\nconst STAFF_CACHE_MS = 60_000\n\nasync function listStaffUids(): Promise<string[]> {\n const now = Date.now()\n if (staffUidCache && now - staffUidCache.at < STAFF_CACHE_MS) {\n return staffUidCache.uids\n }\n // Across ALL auth pools (AGL-1122). This scanned only the project pool, so\n // a staff member who signs in through enterprise SSO — whose account lives\n // in a GCIP tenant pool — was never in the fan-out. Nothing errored; the\n // notification simply never arrived, which is the worst shape a miss can\n // take on an alerting path.\n const uids = await listStaffUidsAcrossPools()\n staffUidCache = { uids, at: now }\n return uids\n}\n\n/**\n * Notifies every staff-claim holder (AGL-850) — the support-desk audience.\n * Staff are not org members, so `notifyOrgAdmins`/`notifyHostManagers` never\n * reach them; this enumerates the `staff` custom claim instead. The docs land\n * at `users/{uid}/notifications`, which the console notifications menu already\n * renders, so no separate staff inbox is needed. Never throws.\n */\nexport async function notifyStaff(payload: NotificationPayload): Promise<void> {\n try {\n await notifyUsers(await listStaffUids(), payload)\n } catch (error) {\n console.error('staff notification failed', error)\n }\n}\n\n/** Notifies the org's owner + admins (billing, membership, org events). */\nexport async function notifyOrgAdmins(\n orgId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const members = await listOrgMembers(orgId)\n const admins = members.filter(\n (member) => member.role === 'owner' || member.role === 'admin',\n )\n // The roster rows carry addresses, so the email channel (AGL-3224) needs\n // no directory lookup for anyone reached this way.\n const emails: Record<string, string | undefined> = {}\n for (const member of admins) {\n if (member.email) emails[member.$id] = member.email\n }\n await notifyUsers(\n admins.map((member) => member.$id),\n { ...payload, orgId },\n { emails },\n )\n } catch (error) {\n console.error('org admin notification failed', error)\n }\n}\n\n/**\n * Notifies everyone who manages a host (admin/editor in the host doc's\n * `memberRoles` projection) — the audience for form submissions and\n * bookings on that site.\n *\n * The OWNING ORG travels with the notification (AGL-1773). Host links are\n * stored in the legacy `/{hostDocId}/rest` shape and rewritten to\n * `/{orgSlug}/hosts/{subdomain}/rest` when they are FOLLOWED\n * (`normalizeNotificationLink`, AGL-644) — a rewrite that needs an org slug.\n * With no `orgId` on the doc the console had nothing to key on and fell back\n * to whichever org the reader happens to have OPEN, so a manager who belongs\n * to more than one workspace was routed to `/{other-org}/hosts/{subdomain}/…`\n * and got a designed 404: `HostGuard` only resolves subdomains belonging to\n * the current org. `notifyOrgAdmins` has always stamped it; the host fan-out\n * — every form submission, booking, order and stock alert — never did.\n *\n * The host doc already carries `orgId` (AGL-233), so this costs no extra\n * read. Spread conditionally: the field is optional on the host model and\n * Firestore rejects `undefined`.\n */\nexport async function notifyHostManagers(\n hostId: string,\n payload: NotificationPayload,\n): Promise<void> {\n try {\n const host = await firestore().collection('hosts').doc(hostId).get()\n const memberRoles =\n (host.get('memberRoles') as Record<string, string> | undefined) ?? {}\n const managers = Object.entries(memberRoles)\n .filter(([, role]) => role === 'admin' || role === 'editor')\n .map(([uid]) => uid)\n const orgId = payload.orgId ?? (host.get('orgId') as string | undefined)\n await notifyUsers(managers, {\n ...payload,\n hostId,\n ...(orgId ? { orgId } : {}),\n })\n } catch (error) {\n console.error('host manager notification failed', error)\n }\n}\n"],"names":["buildRoute","NOTIFICATION_SELF_SENT_EMAIL_TYPES","NOTIFICATION_SETTINGS_FIELD","notificationChannelEnabled","Route","isEmailConfigured","sendEmail","FieldValue","findUserByUidAcrossPools","listStaffUidsAcrossPools","filterSuppressedEmails","meterOrgEmail","meterPlatformEmail","firebaseAdmin","listOrgMembers","firestore","app","consoleOrigin","process","env","NEXT_PUBLIC_CONSOLE_URL","trim","replace","NOTIFY_EMAIL_MAX_LOOKUPS","NOTIFY_EMAIL_MAX_SENDS","emailNotification","uids","payload","known","origin","settingsUrl","MANAGE_NOTIFICATION_SETTINGS","lookups","sent","uid","address","String","toLowerCase","includes","pooled","catch","record","email","recipients","length","link","body","title","filter","Boolean","join","result","to","subject","text","context","headers","orgId","undefined","notifyUsers","options","db","targets","Set","slice","userDocs","getAll","map","collection","doc","scope","hostId","batch","count","mailTo","userDoc","settings","get","legacy","type","set","id","read","createdAt","serverTimestamp","push","commit","has","emails","error","console","staffUidCache","STAFF_CACHE_MS","listStaffUids","now","Date","at","notifyStaff","notifyOrgAdmins","members","admins","member","role","$id","notifyHostManagers","host","memberRoles","managers","Object","entries"],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ type Data = Record<string, any>;
18
+ export interface QueryFakeFirestore {
19
+ collection(path: string): any;
20
+ doc(path: string): any;
21
+ getAll(...refs: any[]): Promise<any[]>;
22
+ runTransaction<T>(body: (transaction: any) => Promise<T>): Promise<T>;
23
+ batch(): any;
24
+ bulkWriter(): any;
25
+ /** Seed or overwrite one document, with no transforms and no write counted. */
26
+ seed(path: string, data: Data): void;
27
+ /** One document's data as stored, or `undefined`. */
28
+ read(path: string): Data | undefined;
29
+ /** Every document directly in a collection, by id. */
30
+ docs(collectionPath: string): Record<string, Data>;
31
+ /** Committed writes since the last reset. */
32
+ writes(): number;
33
+ resetWrites(): void;
34
+ /** The clock `serverTimestamp()` and every `updateTime` read. */
35
+ setNow(ms: number): void;
36
+ /** Makes the next writes to `path` fail with `error` — a stand-in for an outage. */
37
+ failWritesTo(path: string, error: Error | null): void;
38
+ }
39
+ export declare function queryFakeFirestore(seed?: Record<string, Data>, options?: {
40
+ nowMs?: number;
41
+ }): QueryFakeFirestore;
42
+ export {};