@aglyn/tenant-data-admin 1.0.0-beta.165 → 1.0.0-beta.166
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.
- package/package.json +6 -6
- package/src/lib/server/contact-merge.js +24 -19
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +8 -3
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-inbound-email.js +12 -2
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +8 -1
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.js +20 -1
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +78 -0
- package/src/lib/server/host-visitor-records.js +238 -17
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/organizations.d.ts +1 -1
- package/src/lib/server/organizations.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n// The leaves, not the barrel — see `email-suppression.ts` for why: a spec\n// of this sweep substitutes a store, not the pure helpers it leans on.\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n runPluginPersonErasers,\n type PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { companyContactsCountFields } from './contact-company-link'\nimport { eraseEmailDeliveriesForAddresses } from './email-delivery-log'\nimport { suppressEmailForHostErasure } from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport { listMemberDocIds } from './list-members'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; a page under it keeps a margin. */\nconst PAGE = 400\n\n/**\n * A pathological collection must not hold the erasure open indefinitely:\n * twenty pages of four hundred is eight thousand rows of one kind for one\n * person, past anything a real relationship produces.\n */\nconst MAX_PAGES = 20\n\nexport interface ErasePersonOptions {\n orgId: string\n /** Any spelling; normalized before anything is looked up. */\n email: unknown\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to `Date.now()`. */\n now?: number\n}\n\n/**\n * Counts, never identities: this is what the request document and the\n * audit row record, and both are read by people who must not learn the\n * address from them.\n */\nexport interface ErasePersonCounts {\n /** Sites in the workspace the sweep walked. */\n hosts: number\n /** Suppression rows written, one per site. */\n hostsSuppressed: number\n /** Contact documents deleted — the row every site shared. */\n contacts: number\n /** Companies whose contact count moved down. */\n companyLinks: number\n /** Deals whose `contactId` was removed. */\n deals: number\n tasks: number\n activities: number\n /** `hosts/{hostId}/leads/{personKey}` rows deleted. */\n leads: number\n /** Audience-list member rows deleted, across every list. */\n listMemberships: number\n /** Orders with the buyer's identity removed; the record stays. */\n orders: number\n /** Bookings with the person's identity removed; the record stays. */\n bookings: number\n /** Delivery-log messages deleted under the address. */\n emailDeliveries: number\n /**\n * Each plugin's share (AGL-2981), by plugin id: its eraser's own counts,\n * or `null` for an eraser that failed — its data may remain.\n */\n plugins: Record<string, PluginPersonErasureReport | null>\n}\n\nexport type ErasePersonResult =\n | ({ ok: true } & ErasePersonCounts)\n | { ok: false; skippedReason: 'invalid-email' }\n\n/**\n * Remove one person from one workspace (AGL-2623).\n *\n * `planContactDetach` is deliberately not consulted: the CRM's delete is a\n * detach that leaves the row for the other holders, and this is the act\n * that must not. The contact document goes whole — every site's facet,\n * every consent entry, every attribution — and with it everything the\n * workspace keeps beside the person.\n *\n * ## Order\n *\n * 1. **Suppress first.** One row per site on the per-site suppression\n * list, before any delete: a form filled in while the sweep runs must\n * already find the door closed, or the sweep deletes a row that the\n * capture re-creates a moment later.\n * 2. The plugins' share: every eraser registered on\n * `plugin-person-erasure`, handed the address, its key and the ids of\n * the contacts about to go, while those documents still exist.\n * 3. The contact and its satellites — company counts, deals unlinked,\n * tasks and activities deleted — by the contact's id, then the\n * document itself.\n * 4. Leads, list memberships, orders and bookings by the address, on\n * every site of the workspace.\n * 5. The delivery log, last: it is filed under the address alone, and the\n * tombstone it leaves is what keeps a later import from refilling it.\n *\n * ## What is anonymized rather than deleted\n *\n * An order is the merchant's record of a sale and a booking of an\n * appointment; the amounts, the line items and the tax are theirs to keep\n * and the law expects them kept. The person is taken OFF those records —\n * name, email, phone, addresses — and a stamp says when. A deal is the\n * team's own pipeline record and is unlinked. Everything else that names\n * the person is about the person and is deleted.\n *\n * ## What is not reached, and why\n *\n * A form submission keeps the address inside `fields`, under whatever the\n * form called it — there is no key to query by and a scan of every\n * submission on every site is unbounded. A site member's login is their\n * own account. A subscription carries the address it bills, and a live one\n * cannot be anonymized without breaking its receipts. Each is named to the\n * admin by the dialog so they can finish by hand.\n *\n * Every sweep is best-effort against the others: a failure in one is\n * logged and counted as zero, and the request's counts say what happened.\n * The caller decides whether zero contacts on a person it could see is a\n * failure to retry.\n */\nexport async function erasePerson(\n options: ErasePersonOptions,\n): Promise<ErasePersonResult> {\n const email = normalizeContactEmail(options.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return { ok: false, skippedReason: 'invalid-email' }\n const db = options.firestore ?? defaultFirestore()\n const now = options.now ?? Date.now()\n const orgRef = db.collection('orgs').doc(options.orgId)\n\n const counts: ErasePersonCounts = {\n hosts: 0,\n hostsSuppressed: 0,\n contacts: 0,\n companyLinks: 0,\n deals: 0,\n tasks: 0,\n activities: 0,\n leads: 0,\n listMemberships: 0,\n orders: 0,\n bookings: 0,\n emailDeliveries: 0,\n plugins: {},\n }\n\n const hosts = await db\n .collection('hosts')\n .where('orgId', '==', options.orgId)\n .get()\n const hostIds: string[] = hosts.docs.map((doc: any) => String(doc.id))\n counts.hosts = hostIds.length\n\n for (const hostId of hostIds) {\n try {\n const written = await suppressEmailForHostErasure({ hostId, email, firestore: db })\n if (written) counts.hostsSuppressed += 1\n } catch (error) {\n console.error(`erasePerson: suppression write failed for ${hostId}`, error)\n }\n }\n\n const contacts = await orgRef.collection('contacts').where('email', '==', email).get()\n\n // What the plugins keep about the person (AGL-2981): records filed under\n // the organization by the contact or by the address, which none of the\n // sweeps here can see. Every site's door is already closed above, and\n // the contacts are still there for an eraser that reads one. Isolated\n // per plugin: one that fails is recorded as `null` and the erasure goes\n // on. This erasure has no plan of its own, so it is never a dry run.\n counts.plugins = await runPluginPersonErasers({\n orgId: options.orgId,\n email,\n key,\n contactIds: contacts.docs.map((contact: any) => String(contact.id)),\n dryRun: false,\n })\n\n for (const contact of contacts.docs) {\n const contactId = String(contact.id)\n const companyIds: unknown = contact.get('companyIds')\n const linked = Array.isArray(companyIds)\n ? companyIds.filter((id): id is string => typeof id === 'string' && !!id)\n : []\n for (const companyId of new Set(linked)) {\n try {\n await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .update(companyContactsCountFields(-1))\n counts.companyLinks += 1\n } catch (error) {\n console.error(`erasePerson: company count could not move for ${companyId}`, error)\n }\n }\n counts.deals += await updateWhere(\n db,\n orgRef.collection(CRM_COLLECTIONS.deals),\n 'contactId',\n contactId,\n { contactId: FieldValue.delete(), updatedAt: FieldValue.serverTimestamp() },\n )\n counts.tasks += await deleteWhere(db, orgRef.collection(CRM_COLLECTIONS.tasks), 'contactId', contactId)\n counts.activities += await deleteWhere(\n db,\n orgRef.collection(CRM_COLLECTIONS.activities),\n 'contactId',\n contactId,\n )\n try {\n await contact.ref.delete()\n counts.contacts += 1\n } catch (error) {\n console.error(`erasePerson: contact delete failed for ${contactId}`, error)\n }\n }\n\n const erasedOrder = {\n customerEmail: null,\n customerName: null,\n shippingAddress: FieldValue.delete(),\n billingAddress: FieldValue.delete(),\n customerErasedAtMs: now,\n }\n const erasedBooking = {\n email: null,\n name: FieldValue.delete(),\n phone: FieldValue.delete(),\n customerErasedAtMs: now,\n }\n for (const hostId of hostIds) {\n const hostRef = db.collection('hosts').doc(hostId)\n try {\n const lead = hostRef.collection('leads').doc(key)\n if ((await lead.get()).exists) {\n await lead.delete()\n counts.leads += 1\n }\n } catch (error) {\n console.error(`erasePerson: lead delete failed for ${hostId}`, error)\n }\n counts.orders += await updateWhere(db, hostRef.collection('orders'), 'customerEmail', email, erasedOrder)\n counts.bookings += await updateWhere(db, hostRef.collection('bookings'), 'email', email, erasedBooking)\n }\n\n try {\n const memberIds = listMemberDocIds(email)\n const lists = await orgRef.collection('lists').get()\n for (const list of lists.docs) {\n const refs = memberIds.map((id) => list.ref.collection('members').doc(id))\n const found = await db.getAll(...refs)\n for (const member of found) {\n if (!member.exists) continue\n await member.ref.delete()\n counts.listMemberships += 1\n }\n }\n } catch (error) {\n console.error('erasePerson: list membership sweep failed', error)\n }\n\n try {\n const sweep = await eraseEmailDeliveriesForAddresses([{ address: email }], db)\n counts.emailDeliveries = sweep.removed\n } catch (error) {\n console.error('erasePerson: delivery log sweep failed', error)\n }\n\n await db\n .collection('adminAudit')\n .add({\n actorUid: 'system:erase-person',\n action: 'person.erased',\n target: `orgs/${options.orgId}/people/${key}`,\n before: null,\n after: counts,\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return { ok: true, ...counts }\n}\n\n/**\n * Delete every document in `collection` whose `field` equals `value`, a\n * page at a time. Returns how many went; a failure logs and stops, and the\n * count says how far it got.\n */\nasync function deleteWhere(\n db: any,\n collection: any,\n field: string,\n value: string,\n): Promise<number> {\n let removed = 0\n try {\n for (let pass = 0; pass < MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc: any) => batch.delete(doc.ref))\n await batch.commit()\n removed += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`erasePerson: delete sweep failed on ${field}`, error)\n }\n return removed\n}\n\n/**\n * Apply `patch` to every document in `collection` whose `field` equals\n * `value`. Because the patch clears the very field that was matched on, a\n * second page never sees the first page's rows again, so the loop needs no\n * cursor; the page cap is the only bound.\n */\nasync function updateWhere(\n db: any,\n collection: any,\n field: string,\n value: string,\n patch: Record<string, unknown>,\n): Promise<number> {\n let changed = 0\n try {\n for (let pass = 0; pass < MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc: any) => batch.update(doc.ref, patch))\n await batch.commit()\n changed += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`erasePerson: update sweep failed on ${field}`, error)\n }\n return changed\n}\n"],"names":["FieldValue","normalizeContactEmail","CRM_COLLECTIONS","personKey","runPluginPersonErasers","companyContactsCountFields","eraseEmailDeliveriesForAddresses","suppressEmailForHostErasure","firebaseAdmin","listMemberDocIds","defaultFirestore","app","firestore","PAGE","MAX_PAGES","erasePerson","options","email","key","ok","skippedReason","db","now","Date","orgRef","collection","doc","orgId","counts","hosts","hostsSuppressed","contacts","companyLinks","deals","tasks","activities","leads","listMemberships","orders","bookings","emailDeliveries","plugins","where","get","hostIds","docs","map","String","id","length","hostId","written","error","console","contactIds","contact","dryRun","contactId","companyIds","linked","Array","isArray","filter","companyId","Set","companies","update","updateWhere","delete","updatedAt","serverTimestamp","deleteWhere","ref","erasedOrder","customerEmail","customerName","shippingAddress","billingAddress","customerErasedAtMs","erasedBooking","name","phone","hostRef","lead","exists","memberIds","lists","list","refs","found","getAll","member","sweep","address","removed","add","actorUid","action","target","before","after","at","catch","undefined","field","value","pass","page","limit","empty","batch","forEach","commit","size","patch","changed"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,QAAQ,2BAA0B;AACrD,0EAA0E;AAC1E,uEAAuE;AACvE,SAASC,qBAAqB,QAAQ,kCAAiC;AACvE,SAASC,eAAe,QAAQ,6BAA4B;AAC5D,SAASC,SAAS,QAAQ,oCAAmC;AAC7D,SACEC,sBAAsB,QAEjB,oDAAmD;AAC1D,SAASC,0BAA0B,QAAQ,4BAAwB;AACnE,SAASC,gCAAgC,QAAQ,0BAAsB;AACvE,SAASC,2BAA2B,QAAQ,yBAAqB;AACjE,OAAOC,mBAAmB,sBAAkB;AAC5C,SAASC,gBAAgB,QAAQ,oBAAgB;AAEjD,MAAMC,mBAAmB,IAAMF,cAAcG,GAAG,GAAGC,SAAS;AAE5D,0EAA0E,GAC1E,MAAMC,OAAO;AAEb;;;;CAIC,GACD,MAAMC,YAAY;AAmDlB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgDC,GACD,OAAO,eAAeC,YACpBC,OAA2B;QAKhBA,oBACCA;IAJZ,MAAMC,QAAQhB,sBAAsBe,QAAQC,KAAK;IACjD,MAAMC,MAAMD,QAAQd,UAAUc,SAAS;IACvC,IAAI,CAACA,SAAS,CAACC,KAAK,OAAO;QAAEC,IAAI;QAAOC,eAAe;IAAgB;IACvE,MAAMC,MAAKL,qBAAAA,QAAQJ,SAAS,YAAjBI,qBAAqBN;IAChC,MAAMY,OAAMN,eAAAA,QAAQM,GAAG,YAAXN,eAAeO,KAAKD,GAAG;IACnC,MAAME,SAASH,GAAGI,UAAU,CAAC,QAAQC,GAAG,CAACV,QAAQW,KAAK;IAEtD,MAAMC,SAA4B;QAChCC,OAAO;QACPC,iBAAiB;QACjBC,UAAU;QACVC,cAAc;QACdC,OAAO;QACPC,OAAO;QACPC,YAAY;QACZC,OAAO;QACPC,iBAAiB;QACjBC,QAAQ;QACRC,UAAU;QACVC,iBAAiB;QACjBC,SAAS,CAAC;IACZ;IAEA,MAAMZ,QAAQ,MAAMR,GACjBI,UAAU,CAAC,SACXiB,KAAK,CAAC,SAAS,MAAM1B,QAAQW,KAAK,EAClCgB,GAAG;IACN,MAAMC,UAAoBf,MAAMgB,IAAI,CAACC,GAAG,CAAC,CAACpB,MAAaqB,OAAOrB,IAAIsB,EAAE;IACpEpB,OAAOC,KAAK,GAAGe,QAAQK,MAAM;IAE7B,KAAK,MAAMC,UAAUN,QAAS;QAC5B,IAAI;YACF,MAAMO,UAAU,MAAM5C,4BAA4B;gBAAE2C;gBAAQjC;gBAAOL,WAAWS;YAAG;YACjF,IAAI8B,SAASvB,OAAOE,eAAe,IAAI;QACzC,EAAE,OAAOsB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,0CAA0C,EAAEF,QAAQ,EAAEE;QACvE;IACF;IAEA,MAAMrB,WAAW,MAAMP,OAAOC,UAAU,CAAC,YAAYiB,KAAK,CAAC,SAAS,MAAMzB,OAAO0B,GAAG;IAEpF,yEAAyE;IACzE,uEAAuE;IACvE,sEAAsE;IACtE,sEAAsE;IACtE,wEAAwE;IACxE,qEAAqE;IACrEf,OAAOa,OAAO,GAAG,MAAMrC,uBAAuB;QAC5CuB,OAAOX,QAAQW,KAAK;QACpBV;QACAC;QACAoC,YAAYvB,SAASc,IAAI,CAACC,GAAG,CAAC,CAACS,UAAiBR,OAAOQ,QAAQP,EAAE;QACjEQ,QAAQ;IACV;IAEA,KAAK,MAAMD,WAAWxB,SAASc,IAAI,CAAE;QACnC,MAAMY,YAAYV,OAAOQ,QAAQP,EAAE;QACnC,MAAMU,aAAsBH,QAAQZ,GAAG,CAAC;QACxC,MAAMgB,SAASC,MAAMC,OAAO,CAACH,cACzBA,WAAWI,MAAM,CAAC,CAACd,KAAqB,OAAOA,OAAO,YAAY,CAAC,CAACA,MACpE,EAAE;QACN,KAAK,MAAMe,aAAa,IAAIC,IAAIL,QAAS;YACvC,IAAI;gBACF,MAAMnC,OACHC,UAAU,CAACvB,gBAAgB+D,SAAS,EACpCvC,GAAG,CAACqC,WACJG,MAAM,CAAC7D,2BAA2B,CAAC;gBACtCuB,OAAOI,YAAY,IAAI;YACzB,EAAE,OAAOoB,OAAO;gBACdC,QAAQD,KAAK,CAAC,CAAC,8CAA8C,EAAEW,WAAW,EAAEX;YAC9E;QACF;QACAxB,OAAOK,KAAK,IAAI,MAAMkC,YACpB9C,IACAG,OAAOC,UAAU,CAACvB,gBAAgB+B,KAAK,GACvC,aACAwB,WACA;YAAEA,WAAWzD,WAAWoE,MAAM;YAAIC,WAAWrE,WAAWsE,eAAe;QAAG;QAE5E1C,OAAOM,KAAK,IAAI,MAAMqC,YAAYlD,IAAIG,OAAOC,UAAU,CAACvB,gBAAgBgC,KAAK,GAAG,aAAauB;QAC7F7B,OAAOO,UAAU,IAAI,MAAMoC,YACzBlD,IACAG,OAAOC,UAAU,CAACvB,gBAAgBiC,UAAU,GAC5C,aACAsB;QAEF,IAAI;YACF,MAAMF,QAAQiB,GAAG,CAACJ,MAAM;YACxBxC,OAAOG,QAAQ,IAAI;QACrB,EAAE,OAAOqB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,uCAAuC,EAAEK,WAAW,EAAEL;QACvE;IACF;IAEA,MAAMqB,cAAc;QAClBC,eAAe;QACfC,cAAc;QACdC,iBAAiB5E,WAAWoE,MAAM;QAClCS,gBAAgB7E,WAAWoE,MAAM;QACjCU,oBAAoBxD;IACtB;IACA,MAAMyD,gBAAgB;QACpB9D,OAAO;QACP+D,MAAMhF,WAAWoE,MAAM;QACvBa,OAAOjF,WAAWoE,MAAM;QACxBU,oBAAoBxD;IACtB;IACA,KAAK,MAAM4B,UAAUN,QAAS;QAC5B,MAAMsC,UAAU7D,GAAGI,UAAU,CAAC,SAASC,GAAG,CAACwB;QAC3C,IAAI;YACF,MAAMiC,OAAOD,QAAQzD,UAAU,CAAC,SAASC,GAAG,CAACR;YAC7C,IAAI,AAAC,CAAA,MAAMiE,KAAKxC,GAAG,EAAC,EAAGyC,MAAM,EAAE;gBAC7B,MAAMD,KAAKf,MAAM;gBACjBxC,OAAOQ,KAAK,IAAI;YAClB;QACF,EAAE,OAAOgB,OAAO;YACdC,QAAQD,KAAK,CAAC,CAAC,oCAAoC,EAAEF,QAAQ,EAAEE;QACjE;QACAxB,OAAOU,MAAM,IAAI,MAAM6B,YAAY9C,IAAI6D,QAAQzD,UAAU,CAAC,WAAW,iBAAiBR,OAAOwD;QAC7F7C,OAAOW,QAAQ,IAAI,MAAM4B,YAAY9C,IAAI6D,QAAQzD,UAAU,CAAC,aAAa,SAASR,OAAO8D;IAC3F;IAEA,IAAI;QACF,MAAMM,YAAY5E,iBAAiBQ;QACnC,MAAMqE,QAAQ,MAAM9D,OAAOC,UAAU,CAAC,SAASkB,GAAG;QAClD,KAAK,MAAM4C,QAAQD,MAAMzC,IAAI,CAAE;YAC7B,MAAM2C,OAAOH,UAAUvC,GAAG,CAAC,CAACE,KAAOuC,KAAKf,GAAG,CAAC/C,UAAU,CAAC,WAAWC,GAAG,CAACsB;YACtE,MAAMyC,QAAQ,MAAMpE,GAAGqE,MAAM,IAAIF;YACjC,KAAK,MAAMG,UAAUF,MAAO;gBAC1B,IAAI,CAACE,OAAOP,MAAM,EAAE;gBACpB,MAAMO,OAAOnB,GAAG,CAACJ,MAAM;gBACvBxC,OAAOS,eAAe,IAAI;YAC5B;QACF;IACF,EAAE,OAAOe,OAAO;QACdC,QAAQD,KAAK,CAAC,6CAA6CA;IAC7D;IAEA,IAAI;QACF,MAAMwC,QAAQ,MAAMtF,iCAAiC;YAAC;gBAAEuF,SAAS5E;YAAM;SAAE,EAAEI;QAC3EO,OAAOY,eAAe,GAAGoD,MAAME,OAAO;IACxC,EAAE,OAAO1C,OAAO;QACdC,QAAQD,KAAK,CAAC,0CAA0CA;IAC1D;IAEA,MAAM/B,GACHI,UAAU,CAAC,cACXsE,GAAG,CAAC;QACHC,UAAU;QACVC,QAAQ;QACRC,QAAQ,CAAC,KAAK,EAAElF,QAAQW,KAAK,CAAC,QAAQ,EAAET,KAAK;QAC7CiF,QAAQ;QACRC,OAAOxE;QACPyE,IAAIrG,WAAWsE,eAAe;IAChC,GACCgC,KAAK,CAAC,IAAMC;IAEf,OAAO;QAAEpF,IAAI;OAASS;AACxB;AAEA;;;;CAIC,GACD,eAAe2C,YACblD,EAAO,EACPI,UAAe,EACf+E,KAAa,EACbC,KAAa;IAEb,IAAIX,UAAU;IACd,IAAI;QACF,IAAK,IAAIY,OAAO,GAAGA,OAAO5F,WAAW4F,QAAQ,EAAG;YAC9C,MAAMC,OAAO,MAAMlF,WAAWiB,KAAK,CAAC8D,OAAO,MAAMC,OAAOG,KAAK,CAAC/F,MAAM8B,GAAG;YACvE,IAAIgE,KAAKE,KAAK,EAAE;YAChB,MAAMC,QAAQzF,GAAGyF,KAAK;YACtBH,KAAK9D,IAAI,CAACkE,OAAO,CAAC,CAACrF,MAAaoF,MAAM1C,MAAM,CAAC1C,IAAI8C,GAAG;YACpD,MAAMsC,MAAME,MAAM;YAClBlB,WAAWa,KAAKM,IAAI;YACpB,IAAIN,KAAKM,IAAI,GAAGpG,MAAM;QACxB;IACF,EAAE,OAAOuC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,oCAAoC,EAAEoD,OAAO,EAAEpD;IAChE;IACA,OAAO0C;AACT;AAEA;;;;;CAKC,GACD,eAAe3B,YACb9C,EAAO,EACPI,UAAe,EACf+E,KAAa,EACbC,KAAa,EACbS,KAA8B;IAE9B,IAAIC,UAAU;IACd,IAAI;QACF,IAAK,IAAIT,OAAO,GAAGA,OAAO5F,WAAW4F,QAAQ,EAAG;YAC9C,MAAMC,OAAO,MAAMlF,WAAWiB,KAAK,CAAC8D,OAAO,MAAMC,OAAOG,KAAK,CAAC/F,MAAM8B,GAAG;YACvE,IAAIgE,KAAKE,KAAK,EAAE;YAChB,MAAMC,QAAQzF,GAAGyF,KAAK;YACtBH,KAAK9D,IAAI,CAACkE,OAAO,CAAC,CAACrF,MAAaoF,MAAM5C,MAAM,CAACxC,IAAI8C,GAAG,EAAE0C;YACtD,MAAMJ,MAAME,MAAM;YAClBG,WAAWR,KAAKM,IAAI;YACpB,IAAIN,KAAKM,IAAI,GAAGpG,MAAM;QACxB;IACF,EAAE,OAAOuC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,oCAAoC,EAAEoD,OAAO,EAAEpD;IAChE;IACA,OAAO+D;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/erase-person.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\n// The leaves, not the barrel — see `email-suppression.ts` for why: a spec\n// of this sweep substitutes a store, not the pure helpers it leans on.\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport {\n runPluginPersonErasers,\n type PluginPersonErasureReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { companyContactsCountFields } from './contact-company-link'\nimport { eraseEmailDeliveriesForAddresses } from './email-delivery-log'\nimport { suppressEmailForHostErasure } from './email-suppression'\nimport firebaseAdmin from './firebase-admin'\nimport { listMemberDocIds } from './list-members'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/** Firestore caps a batch at 500 writes; a page under it keeps a margin. */\nconst PAGE = 400\n\n/**\n * A pathological collection must not hold the erasure open indefinitely:\n * twenty pages of four hundred is eight thousand rows of one kind for one\n * person, past anything a real relationship produces.\n */\nconst MAX_PAGES = 20\n\nexport interface ErasePersonOptions {\n orgId: string\n /** Any spelling; normalized before anything is looked up. */\n email: unknown\n /** Injectable for tests; defaults to the admin app's Firestore. */\n firestore?: any\n /** Injectable for tests; defaults to `Date.now()`. */\n now?: number\n}\n\n/**\n * Counts, never identities: this is what the request document and the\n * audit row record, and both are read by people who must not learn the\n * address from them.\n */\nexport interface ErasePersonCounts {\n /** Sites in the workspace the sweep walked. */\n hosts: number\n /** Suppression rows written, one per site. */\n hostsSuppressed: number\n /** Contact documents deleted — the row every site shared. */\n contacts: number\n /** Companies whose contact count moved down. */\n companyLinks: number\n /** Deals whose `contactId` was removed. */\n deals: number\n tasks: number\n activities: number\n /** `hosts/{hostId}/leads/{personKey}` rows deleted. */\n leads: number\n /** Audience-list member rows deleted, across every list. */\n listMemberships: number\n /** Orders with the buyer's identity removed; the record stays. */\n orders: number\n /** Bookings with the person's identity removed; the record stays. */\n bookings: number\n /** Delivery-log messages deleted under the address. */\n emailDeliveries: number\n /**\n * Each plugin's share (AGL-2981), by plugin id: its eraser's own counts,\n * or `null` for an eraser that failed — its data may remain.\n */\n plugins: Record<string, PluginPersonErasureReport | null>\n}\n\nexport type ErasePersonResult =\n | ({ ok: true } & ErasePersonCounts)\n | { ok: false; skippedReason: 'invalid-email' }\n\n/**\n * Remove one person from one workspace (AGL-2623).\n *\n * `planContactDetach` is deliberately not consulted: the CRM's delete is a\n * detach that leaves the row for the other holders, and this is the act\n * that must not. The contact document goes whole — every site's facet,\n * every consent entry, every attribution — and with it everything the\n * workspace keeps beside the person.\n *\n * ## Order\n *\n * 1. **Suppress first.** One row per site on the per-site suppression\n * list, before any delete: a form filled in while the sweep runs must\n * already find the door closed, or the sweep deletes a row that the\n * capture re-creates a moment later.\n * 2. The plugins' share: every eraser registered on\n * `plugin-person-erasure`, handed the address, its key and the ids of\n * the contacts about to go, while those documents still exist.\n * 3. The contact and its satellites — company counts, deals unlinked,\n * tasks and activities deleted — by the contact's id, then the\n * document itself.\n * 4. Leads, list memberships, orders and bookings by the address, on\n * every site of the workspace.\n * 5. The delivery log, last: it is filed under the address alone, and the\n * tombstone it leaves is what keeps a later import from refilling it.\n *\n * ## What is anonymized rather than deleted\n *\n * An order is the merchant's record of a sale and a booking of an\n * appointment; the amounts, the line items and the tax are theirs to keep\n * and the law expects them kept. The person is taken OFF those records —\n * name, email, phone, addresses — and a stamp says when. A deal is the\n * team's own pipeline record and is unlinked. Everything else that names\n * the person is about the person and is deleted.\n *\n * ## What is not reached, and why\n *\n * A form submission keeps the address inside `fields`, under whatever the\n * form called it — there is no key to query by and a scan of every\n * submission on every site is unbounded. A site member's login is their\n * own account. A subscription carries the address it bills, and a live one\n * cannot be anonymized without breaking its receipts. Each is named to the\n * admin by the dialog so they can finish by hand.\n *\n * Every sweep is best-effort against the others: a failure in one is\n * logged and counted as zero, and the request's counts say what happened.\n * The caller decides whether zero contacts on a person it could see is a\n * failure to retry.\n */\nexport async function erasePerson(\n options: ErasePersonOptions,\n): Promise<ErasePersonResult> {\n const email = normalizeContactEmail(options.email)\n const key = email ? personKey(email) : null\n if (!email || !key) return { ok: false, skippedReason: 'invalid-email' }\n const db = options.firestore ?? defaultFirestore()\n const now = options.now ?? Date.now()\n const orgRef = db.collection('orgs').doc(options.orgId)\n\n const counts: ErasePersonCounts = {\n hosts: 0,\n hostsSuppressed: 0,\n contacts: 0,\n companyLinks: 0,\n deals: 0,\n tasks: 0,\n activities: 0,\n leads: 0,\n listMemberships: 0,\n orders: 0,\n bookings: 0,\n emailDeliveries: 0,\n plugins: {},\n }\n\n const hosts = await db\n .collection('hosts')\n .where('orgId', '==', options.orgId)\n .get()\n const hostIds: string[] = hosts.docs.map((doc: any) => String(doc.id))\n counts.hosts = hostIds.length\n\n for (const hostId of hostIds) {\n try {\n const written = await suppressEmailForHostErasure({ hostId, email, firestore: db })\n if (written) counts.hostsSuppressed += 1\n } catch (error) {\n console.error(`erasePerson: suppression write failed for ${hostId}`, error)\n }\n }\n\n const contacts = await orgRef.collection('contacts').where('email', '==', email).get()\n\n // What the plugins keep about the person (AGL-2981): records filed under\n // the organization by the contact or by the address, which none of the\n // sweeps here can see. Every site's door is already closed above, and\n // the contacts are still there for an eraser that reads one. Isolated\n // per plugin: one that fails is recorded as `null` and the erasure goes\n // on. This erasure has no plan of its own, so it is never a dry run.\n counts.plugins = await runPluginPersonErasers({\n orgId: options.orgId,\n email,\n key,\n contactIds: contacts.docs.map((contact: any) => String(contact.id)),\n dryRun: false,\n })\n\n for (const contact of contacts.docs) {\n const contactId = String(contact.id)\n const companyIds: unknown = contact.get('companyIds')\n const linked = Array.isArray(companyIds)\n ? companyIds.filter((id): id is string => typeof id === 'string' && !!id)\n : []\n for (const companyId of new Set(linked)) {\n try {\n await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .update(companyContactsCountFields(-1))\n counts.companyLinks += 1\n } catch (error) {\n console.error(`erasePerson: company count could not move for ${companyId}`, error)\n }\n }\n counts.deals += await updateWhere(\n db,\n orgRef.collection(CRM_COLLECTIONS.deals),\n 'contactId',\n contactId,\n { contactId: FieldValue.delete(), updatedAt: FieldValue.serverTimestamp() },\n )\n counts.tasks += await deleteWhere(db, orgRef.collection(CRM_COLLECTIONS.tasks), 'contactId', contactId)\n counts.activities += await deleteWhere(\n db,\n orgRef.collection(CRM_COLLECTIONS.activities),\n 'contactId',\n contactId,\n )\n try {\n await contact.ref.delete()\n counts.contacts += 1\n } catch (error) {\n console.error(`erasePerson: contact delete failed for ${contactId}`, error)\n }\n }\n\n const erasedOrder = {\n customerEmail: null,\n customerName: null,\n shippingAddress: FieldValue.delete(),\n billingAddress: FieldValue.delete(),\n customerErasedAtMs: now,\n }\n const erasedBooking = {\n email: null,\n name: FieldValue.delete(),\n phone: FieldValue.delete(),\n customerErasedAtMs: now,\n }\n /*\n * THE ORG ROW, ONCE (AGL-3275) — and then every legacy row still standing.\n *\n * A lead is one document for the whole org now, so the delete leaves the\n * per-site loop. It is not the caller that may assume the migration is\n * finished, though: until AGL-3276 has folded every site and AGL-3277 has\n * removed the fallback, a person can still be held at\n * `hosts/{hostId}/leads`, and an erasure that deleted only the org row would\n * leave that copy behind. So both are swept, and `counts.leads` is what was\n * actually destroyed rather than how many places were looked at.\n */\n try {\n const orgLead = orgRef.collection('leads').doc(key)\n if ((await orgLead.get()).exists) {\n await orgLead.delete()\n counts.leads += 1\n }\n } catch (error) {\n console.error('erasePerson: org lead delete failed', error)\n }\n for (const hostId of hostIds) {\n const hostRef = db.collection('hosts').doc(hostId)\n try {\n const lead = hostRef.collection('leads').doc(key)\n if ((await lead.get()).exists) {\n await lead.delete()\n counts.leads += 1\n }\n } catch (error) {\n console.error(`erasePerson: legacy lead delete failed for ${hostId}`, error)\n }\n counts.orders += await updateWhere(db, hostRef.collection('orders'), 'customerEmail', email, erasedOrder)\n counts.bookings += await updateWhere(db, hostRef.collection('bookings'), 'email', email, erasedBooking)\n }\n\n try {\n const memberIds = listMemberDocIds(email)\n const lists = await orgRef.collection('lists').get()\n for (const list of lists.docs) {\n const refs = memberIds.map((id) => list.ref.collection('members').doc(id))\n const found = await db.getAll(...refs)\n for (const member of found) {\n if (!member.exists) continue\n await member.ref.delete()\n counts.listMemberships += 1\n }\n }\n } catch (error) {\n console.error('erasePerson: list membership sweep failed', error)\n }\n\n try {\n const sweep = await eraseEmailDeliveriesForAddresses([{ address: email }], db)\n counts.emailDeliveries = sweep.removed\n } catch (error) {\n console.error('erasePerson: delivery log sweep failed', error)\n }\n\n await db\n .collection('adminAudit')\n .add({\n actorUid: 'system:erase-person',\n action: 'person.erased',\n target: `orgs/${options.orgId}/people/${key}`,\n before: null,\n after: counts,\n at: FieldValue.serverTimestamp(),\n })\n .catch(() => undefined)\n\n return { ok: true, ...counts }\n}\n\n/**\n * Delete every document in `collection` whose `field` equals `value`, a\n * page at a time. Returns how many went; a failure logs and stops, and the\n * count says how far it got.\n */\nasync function deleteWhere(\n db: any,\n collection: any,\n field: string,\n value: string,\n): Promise<number> {\n let removed = 0\n try {\n for (let pass = 0; pass < MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc: any) => batch.delete(doc.ref))\n await batch.commit()\n removed += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`erasePerson: delete sweep failed on ${field}`, error)\n }\n return removed\n}\n\n/**\n * Apply `patch` to every document in `collection` whose `field` equals\n * `value`. Because the patch clears the very field that was matched on, a\n * second page never sees the first page's rows again, so the loop needs no\n * cursor; the page cap is the only bound.\n */\nasync function updateWhere(\n db: any,\n collection: any,\n field: string,\n value: string,\n patch: Record<string, unknown>,\n): Promise<number> {\n let changed = 0\n try {\n for (let pass = 0; pass < MAX_PAGES; pass += 1) {\n const page = await collection.where(field, '==', value).limit(PAGE).get()\n if (page.empty) break\n const batch = db.batch()\n page.docs.forEach((doc: any) => batch.update(doc.ref, patch))\n await batch.commit()\n changed += page.size\n if (page.size < PAGE) break\n }\n } catch (error) {\n console.error(`erasePerson: update sweep failed on ${field}`, error)\n }\n return changed\n}\n"],"names":["FieldValue","normalizeContactEmail","CRM_COLLECTIONS","personKey","runPluginPersonErasers","companyContactsCountFields","eraseEmailDeliveriesForAddresses","suppressEmailForHostErasure","firebaseAdmin","listMemberDocIds","defaultFirestore","app","firestore","PAGE","MAX_PAGES","erasePerson","options","email","key","ok","skippedReason","db","now","Date","orgRef","collection","doc","orgId","counts","hosts","hostsSuppressed","contacts","companyLinks","deals","tasks","activities","leads","listMemberships","orders","bookings","emailDeliveries","plugins","where","get","hostIds","docs","map","String","id","length","hostId","written","error","console","contactIds","contact","dryRun","contactId","companyIds","linked","Array","isArray","filter","companyId","Set","companies","update","updateWhere","delete","updatedAt","serverTimestamp","deleteWhere","ref","erasedOrder","customerEmail","customerName","shippingAddress","billingAddress","customerErasedAtMs","erasedBooking","name","phone","orgLead","exists","hostRef","lead","memberIds","lists","list","refs","found","getAll","member","sweep","address","removed","add","actorUid","action","target","before","after","at","catch","undefined","field","value","pass","page","limit","empty","batch","forEach","commit","size","patch","changed"],"mappings":"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"}
|
|
@@ -25,6 +25,84 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { type VisitorRecordKind } from '@aglyn/aglyn/server';
|
|
27
27
|
import { type ResolvedCampaignTouch } from './campaign-conversion-attribution';
|
|
28
|
+
import type { ConsentGroup, ScopeToken } from '@aglyn/aglyn/server';
|
|
29
|
+
/**
|
|
30
|
+
* The org collection a lead is written to. Always the org — see the
|
|
31
|
+
* read-only note above.
|
|
32
|
+
*/
|
|
33
|
+
export declare function orgLeadsForHost(hostId: string): Promise<FirebaseFirestore.CollectionReference>;
|
|
34
|
+
/**
|
|
35
|
+
* The leads one site may LIST, narrowed by `visibleTo`.
|
|
36
|
+
*
|
|
37
|
+
* The Admin SDK does not evaluate rules, so an unnarrowed read here would
|
|
38
|
+
* serve one agency client's leads on another's site — the same reason
|
|
39
|
+
* `scopedToHost` exists for every other org-owned collection.
|
|
40
|
+
*/
|
|
41
|
+
export declare function orgLeadsQueryForHost(hostId: string): Promise<{
|
|
42
|
+
ref: FirebaseFirestore.CollectionReference;
|
|
43
|
+
query: FirebaseFirestore.Query;
|
|
44
|
+
}>;
|
|
45
|
+
/**
|
|
46
|
+
* The tokens a reader holding this group may ask `array-contains-any` for.
|
|
47
|
+
* Capped at 30 by `crmReadTokens`, as the contacts list is.
|
|
48
|
+
*/
|
|
49
|
+
export declare function leadReadTokensForHost(hostId: string, group?: ConsentGroup): Promise<ScopeToken[]>;
|
|
50
|
+
/**
|
|
51
|
+
* The `visibleTo` a capture on this site stamps: the consent group's sites,
|
|
52
|
+
* or `['org']` where the org set `defaultResourceScope`.
|
|
53
|
+
*
|
|
54
|
+
* Widened by the capture, never by the lookup — a site that has never
|
|
55
|
+
* captured this person gains nothing by finding them, which is what keeps an
|
|
56
|
+
* agency's clients apart on a record they share. Callers UPDATING an
|
|
57
|
+
* existing lead union these in rather than replacing, exactly as
|
|
58
|
+
* `upsert-contact` does.
|
|
59
|
+
*/
|
|
60
|
+
export declare function leadScopeForHost(hostId: string, org?: Record<string, unknown> | null): Promise<ScopeToken[]>;
|
|
61
|
+
/**
|
|
62
|
+
* The lead for an address, wherever it currently lives.
|
|
63
|
+
*
|
|
64
|
+
* ## The lookup is UNSCOPED, and has to be
|
|
65
|
+
*
|
|
66
|
+
* One human who touched two sibling brands is one person. Narrowing this to
|
|
67
|
+
* what the capturing site may already see would let a second submission on a
|
|
68
|
+
* sibling brand mint a SECOND record for the same address — the duplication
|
|
69
|
+
* this migration exists to end, reintroduced one layer up. Recognizing
|
|
70
|
+
* somebody and being allowed to read their row are different acts:
|
|
71
|
+
* this finds the person, {@link leadScopeForHost} decides who may see them.
|
|
72
|
+
*
|
|
73
|
+
* `upsert-contact` states the same rule at its own dedupe lookup, and for the
|
|
74
|
+
* same reason. A caller that wants only what a site may SEE wants
|
|
75
|
+
* {@link orgLeadsQueryForHost}.
|
|
76
|
+
*
|
|
77
|
+
* @returns the org row; else the not-yet-migrated host row; else `null`.
|
|
78
|
+
*/
|
|
79
|
+
export declare function readLeadForHost(hostId: string, key: string): Promise<FirebaseFirestore.DocumentSnapshot | null>;
|
|
80
|
+
/**
|
|
81
|
+
* The ref a write should target, with any host-path predecessor already
|
|
82
|
+
* carried onto it.
|
|
83
|
+
*
|
|
84
|
+
* A lead that has not been backfilled yet is copied to the org on the next
|
|
85
|
+
* write that touches it, under the capturing site's scope, and the host row
|
|
86
|
+
* is left where it is for AGL-3276 to archive. Two things follow: a write
|
|
87
|
+
* never has to decide which of two rows it is amending, and no later read
|
|
88
|
+
* can be answered by a host row that a write has since moved past.
|
|
89
|
+
*
|
|
90
|
+
* `carried` says whether this call did that copy, so a caller can log it and
|
|
91
|
+
* the backfill's count can be reconciled against it.
|
|
92
|
+
*/
|
|
93
|
+
export declare function leadForWrite(hostId: string, key: string, org?: Record<string, unknown> | null): Promise<{
|
|
94
|
+
ref: FirebaseFirestore.DocumentReference;
|
|
95
|
+
existed: boolean;
|
|
96
|
+
carried: boolean;
|
|
97
|
+
}>;
|
|
98
|
+
/**
|
|
99
|
+
* Whether a host-path lead survives for this address — AGL-3276 reconciling
|
|
100
|
+
* itself, and AGL-3277's deletion check. Not a read path for product code,
|
|
101
|
+
* which wants {@link readLeadForHost}.
|
|
102
|
+
*/
|
|
103
|
+
export declare function legacyLeadExists(hostId: string, key: string): Promise<boolean>;
|
|
104
|
+
/** Every org id this process has resolved a lead for — the backfill's entry point. */
|
|
105
|
+
export declare function orgIdForLeadHost(hostId: string): Promise<string | null>;
|
|
28
106
|
/**
|
|
29
107
|
* 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
|
|
30
108
|
* count as shipped.
|
|
@@ -22,10 +22,174 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
22
22
|
* The policy is pure and lives in `@aglyn/aglyn/server`
|
|
23
23
|
* (`visitor-record-ceiling.ts`); this is the part that needs Firestore. Same
|
|
24
24
|
* split as `visitorWriteRateLimitRefusal`, and for the same reason.
|
|
25
|
-
*/ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis,
|
|
25
|
+
*/ import { CAPTURED_BY_HOST_FIELD, checkVisitorRecordCeiling, LEADS_MAX_PER_HOST, marketingConsentFieldsForGroup, personKey, readMarketingBasis, submissionMonthKey, visitorRecordRefusedCounterId } from "@aglyn/aglyn/server";
|
|
26
26
|
import { FieldValue } from "firebase-admin/firestore";
|
|
27
27
|
import { attributeCampaignConversion } from "./campaign-conversion-attribution.js";
|
|
28
28
|
import { notifyHostManagers } from "./notifications.js";
|
|
29
|
+
import firebaseAdmin from "./firebase-admin.js";
|
|
30
|
+
import { consentGroupForSite, orgDataCollectionForHost, resolveOrgIdForHost, scopedToHost } from "./organizations.js";
|
|
31
|
+
import { crmReadTokens, crmScopeTokens } from "@aglyn/aglyn/server";
|
|
32
|
+
const firestore = ()=>firebaseAdmin.app().firestore();
|
|
33
|
+
/**
|
|
34
|
+
* A lead's one home: `orgs/{orgId}/leads/{personKey}` (AGL-3275).
|
|
35
|
+
*
|
|
36
|
+
* ## Why leads moved
|
|
37
|
+
*
|
|
38
|
+
* A lead used to live at `hosts/{hostId}/leads/{personKey}` while the
|
|
39
|
+
* contact for the same human lived on the org with a `visibleTo` array. The
|
|
40
|
+
* path was doing the job `visibleTo` does, and doing it worse:
|
|
41
|
+
*
|
|
42
|
+
* - An AGENCY was isolated by the path. It is isolated by `visibleTo` too,
|
|
43
|
+
* with no configuration — `consentGroupScope` on a group of one is
|
|
44
|
+
* `['host:{id}']`.
|
|
45
|
+
* - A MULTI-BRAND org could not be served at all. `personKey` is derived
|
|
46
|
+
* from the address, so the same person captured on two sibling brands
|
|
47
|
+
* produced two documents with byte-identical ids under different parents.
|
|
48
|
+
* One person was two records by construction, which is the thing AGL-3232
|
|
49
|
+
* had just finished eliminating for contacts.
|
|
50
|
+
* - The two answers could disagree. A person's contact was visible to every
|
|
51
|
+
* brand in the consent group they actually consented to — the disclosure
|
|
52
|
+
* names those brands — while their lead was visible to whichever brand's
|
|
53
|
+
* form they happened to land on. The lead was scoped more narrowly than
|
|
54
|
+
* what the person was told, which is the safe direction to be wrong in and
|
|
55
|
+
* still the wrong answer.
|
|
56
|
+
*
|
|
57
|
+
* So a lead is stamped with `crmScopeTokens` and read with `crmReadTokens`,
|
|
58
|
+
* the same pair the contacts surface uses. Nothing here is a new mechanism;
|
|
59
|
+
* this is leads arriving at the one the rest of the CRM already had.
|
|
60
|
+
*
|
|
61
|
+
* ## The fallback is READ-ONLY, and temporary
|
|
62
|
+
*
|
|
63
|
+
* This repo has run this migration once already — AGL-237 moved datasets,
|
|
64
|
+
* contacts and media to the org, AGL-1040 backfilled, AGL-1050 deleted the
|
|
65
|
+
* fallback — and `orgDataCollectionForHost` carries what it learned:
|
|
66
|
+
*
|
|
67
|
+
* "a second storage path that can still be WRITTEN is a second boundary to
|
|
68
|
+
* enforce forever, which undoes the premise of scoped sharing: one home
|
|
69
|
+
* per resource plus an explicit scope."
|
|
70
|
+
*
|
|
71
|
+
* So the host path is never written here. It is read, once, to carry a
|
|
72
|
+
* not-yet-migrated lead onto the org as a side effect of the next write that
|
|
73
|
+
* touches it ({@link leadForWrite}) — which shrinks what AGL-3276's backfill
|
|
74
|
+
* has left to fold, and means no read can be answered by a stale host row
|
|
75
|
+
* after a write has moved on. AGL-3277 deletes the fallback and the rules
|
|
76
|
+
* block behind it once that backfill reports nothing left to plan.
|
|
77
|
+
*/ /** The legacy home, for the read half of the migration window only. */ function hostLeads(hostId) {
|
|
78
|
+
return firestore().collection('hosts').doc(hostId).collection('leads');
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The org collection a lead is written to. Always the org — see the
|
|
82
|
+
* read-only note above.
|
|
83
|
+
*/ export async function orgLeadsForHost(hostId) {
|
|
84
|
+
return orgDataCollectionForHost(hostId, 'leads');
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The leads one site may LIST, narrowed by `visibleTo`.
|
|
88
|
+
*
|
|
89
|
+
* The Admin SDK does not evaluate rules, so an unnarrowed read here would
|
|
90
|
+
* serve one agency client's leads on another's site — the same reason
|
|
91
|
+
* `scopedToHost` exists for every other org-owned collection.
|
|
92
|
+
*/ export async function orgLeadsQueryForHost(hostId) {
|
|
93
|
+
const ref = await orgLeadsForHost(hostId);
|
|
94
|
+
return {
|
|
95
|
+
ref,
|
|
96
|
+
query: scopedToHost(ref, hostId)
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The tokens a reader holding this group may ask `array-contains-any` for.
|
|
101
|
+
* Capped at 30 by `crmReadTokens`, as the contacts list is.
|
|
102
|
+
*/ export async function leadReadTokensForHost(hostId, group) {
|
|
103
|
+
return crmReadTokens(group != null ? group : await consentGroupForSite(hostId));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The `visibleTo` a capture on this site stamps: the consent group's sites,
|
|
107
|
+
* or `['org']` where the org set `defaultResourceScope`.
|
|
108
|
+
*
|
|
109
|
+
* Widened by the capture, never by the lookup — a site that has never
|
|
110
|
+
* captured this person gains nothing by finding them, which is what keeps an
|
|
111
|
+
* agency's clients apart on a record they share. Callers UPDATING an
|
|
112
|
+
* existing lead union these in rather than replacing, exactly as
|
|
113
|
+
* `upsert-contact` does.
|
|
114
|
+
*/ export async function leadScopeForHost(hostId, org) {
|
|
115
|
+
const group = await consentGroupForSite(hostId, org);
|
|
116
|
+
return crmScopeTokens(org != null ? org : null, group);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The lead for an address, wherever it currently lives.
|
|
120
|
+
*
|
|
121
|
+
* ## The lookup is UNSCOPED, and has to be
|
|
122
|
+
*
|
|
123
|
+
* One human who touched two sibling brands is one person. Narrowing this to
|
|
124
|
+
* what the capturing site may already see would let a second submission on a
|
|
125
|
+
* sibling brand mint a SECOND record for the same address — the duplication
|
|
126
|
+
* this migration exists to end, reintroduced one layer up. Recognizing
|
|
127
|
+
* somebody and being allowed to read their row are different acts:
|
|
128
|
+
* this finds the person, {@link leadScopeForHost} decides who may see them.
|
|
129
|
+
*
|
|
130
|
+
* `upsert-contact` states the same rule at its own dedupe lookup, and for the
|
|
131
|
+
* same reason. A caller that wants only what a site may SEE wants
|
|
132
|
+
* {@link orgLeadsQueryForHost}.
|
|
133
|
+
*
|
|
134
|
+
* @returns the org row; else the not-yet-migrated host row; else `null`.
|
|
135
|
+
*/ export async function readLeadForHost(hostId, key) {
|
|
136
|
+
const orgRow = await (await orgLeadsForHost(hostId)).doc(key).get();
|
|
137
|
+
if (orgRow.exists) return orgRow;
|
|
138
|
+
const hostRow = await hostLeads(hostId).doc(key).get();
|
|
139
|
+
return hostRow.exists ? hostRow : null;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The ref a write should target, with any host-path predecessor already
|
|
143
|
+
* carried onto it.
|
|
144
|
+
*
|
|
145
|
+
* A lead that has not been backfilled yet is copied to the org on the next
|
|
146
|
+
* write that touches it, under the capturing site's scope, and the host row
|
|
147
|
+
* is left where it is for AGL-3276 to archive. Two things follow: a write
|
|
148
|
+
* never has to decide which of two rows it is amending, and no later read
|
|
149
|
+
* can be answered by a host row that a write has since moved past.
|
|
150
|
+
*
|
|
151
|
+
* `carried` says whether this call did that copy, so a caller can log it and
|
|
152
|
+
* the backfill's count can be reconciled against it.
|
|
153
|
+
*/ export async function leadForWrite(hostId, key, org) {
|
|
154
|
+
var _legacy_data;
|
|
155
|
+
const ref = (await orgLeadsForHost(hostId)).doc(key);
|
|
156
|
+
if ((await ref.get()).exists) return {
|
|
157
|
+
ref,
|
|
158
|
+
existed: true,
|
|
159
|
+
carried: false
|
|
160
|
+
};
|
|
161
|
+
const legacy = await hostLeads(hostId).doc(key).get();
|
|
162
|
+
if (!legacy.exists) return {
|
|
163
|
+
ref,
|
|
164
|
+
existed: false,
|
|
165
|
+
carried: false
|
|
166
|
+
};
|
|
167
|
+
// The legacy row carries no `visibleTo` — it was scoped by its parent — so
|
|
168
|
+
// the capturing site's group supplies one. `set` rather than `create`: a
|
|
169
|
+
// peer carrying the same row in the same second must not fail the write
|
|
170
|
+
// that provoked it, and both writes carry identical field values.
|
|
171
|
+
await ref.set(_extends({}, (_legacy_data = legacy.data()) != null ? _legacy_data : {}, {
|
|
172
|
+
visibleTo: await leadScopeForHost(hostId, org),
|
|
173
|
+
migratedFromHostId: hostId
|
|
174
|
+
}), {
|
|
175
|
+
merge: true
|
|
176
|
+
});
|
|
177
|
+
return {
|
|
178
|
+
ref,
|
|
179
|
+
existed: true,
|
|
180
|
+
carried: true
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Whether a host-path lead survives for this address — AGL-3276 reconciling
|
|
185
|
+
* itself, and AGL-3277's deletion check. Not a read path for product code,
|
|
186
|
+
* which wants {@link readLeadForHost}.
|
|
187
|
+
*/ export async function legacyLeadExists(hostId, key) {
|
|
188
|
+
return (await hostLeads(hostId).doc(key).get()).exists;
|
|
189
|
+
}
|
|
190
|
+
/** Every org id this process has resolved a lead for — the backfill's entry point. */ export async function orgIdForLeadHost(hostId) {
|
|
191
|
+
return resolveOrgIdForHost(hostId);
|
|
192
|
+
}
|
|
29
193
|
/**
|
|
30
194
|
* 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
|
|
31
195
|
* count as shipped.
|
|
@@ -134,7 +298,7 @@ import { notifyHostManagers } from "./notifications.js";
|
|
|
134
298
|
const { hostRef, hostId, lead } = options;
|
|
135
299
|
const maxPerHost = (_options_ceiling = options.ceiling) != null ? _options_ceiling : LEADS_MAX_PER_HOST;
|
|
136
300
|
try {
|
|
137
|
-
const leadsRef =
|
|
301
|
+
const leadsRef = await orgLeadsForHost(hostId);
|
|
138
302
|
const firestore = hostRef.firestore;
|
|
139
303
|
/*
|
|
140
304
|
* `null` for anything that is not a usable address — a lead captured
|
|
@@ -142,7 +306,22 @@ import { notifyHostManagers } from "./notifications.js";
|
|
|
142
306
|
* several unusable addresses under one guessed id would merge two
|
|
143
307
|
* different people, which is worse than two rows for one.
|
|
144
308
|
*/ const key = personKey(lead.email);
|
|
145
|
-
|
|
309
|
+
/*
|
|
310
|
+
* A lead the org has not taken over yet is carried across BEFORE the
|
|
311
|
+
* transaction opens (AGL-3275). It has to happen outside: the carry is
|
|
312
|
+
* itself a read-then-write, and a transaction that also counted the
|
|
313
|
+
* collection would be reading a row it was about to create. Two doors
|
|
314
|
+
* carrying the same person in the same second both write the legacy
|
|
315
|
+
* fields under `merge`, so the race is a no-op rather than a conflict.
|
|
316
|
+
*/ const leadRef = key ? (await leadForWrite(hostId, key)).ref : leadsRef.doc();
|
|
317
|
+
/*
|
|
318
|
+
* The scope this capture stamps, and the group whose terms its consent is
|
|
319
|
+
* recorded under. Both resolved HERE rather than in the transaction body:
|
|
320
|
+
* a contended transaction re-runs that body, and neither of these can
|
|
321
|
+
* change between attempts, so resolving them inside would pay for the org
|
|
322
|
+
* read again on every retry.
|
|
323
|
+
*/ const scope = await leadScopeForHost(hostId);
|
|
324
|
+
const group = await consentGroupForSite(hostId);
|
|
146
325
|
const now = Date.now();
|
|
147
326
|
const seen = _extends({
|
|
148
327
|
// `arrayUnion`, so a person who books twice has `['booking']` and one
|
|
@@ -176,7 +355,20 @@ import { notifyHostManagers } from "./notifications.js";
|
|
|
176
355
|
* It also means the count is only paid on a genuinely new person,
|
|
177
356
|
* which is the case that can move it.
|
|
178
357
|
*/ if (!existing.exists) {
|
|
179
|
-
|
|
358
|
+
/*
|
|
359
|
+
* COUNTED OVER WHAT THIS SITE MAY SEE, not over the org (AGL-3275).
|
|
360
|
+
*
|
|
361
|
+
* The collection is org-wide now, and `LEADS_MAX_PER_HOST` is a
|
|
362
|
+
* per-SITE ceiling. An unfiltered count would charge every brand in
|
|
363
|
+
* an agency's account for every other brand's leads, and the first
|
|
364
|
+
* client to fill its allowance would refuse captures on sites that
|
|
365
|
+
* had taken none. `scopedToHost` is the same narrowing every other
|
|
366
|
+
* org-owned read uses, so a single-site org counts exactly what it
|
|
367
|
+
* counted before this moved.
|
|
368
|
+
*
|
|
369
|
+
* A lead shared by two brands in one consent group is counted by
|
|
370
|
+
* both, which is the honest answer: each of them holds it.
|
|
371
|
+
*/ const used = (await tx.get(scopedToHost(leadsRef, hostId).count())).data().count;
|
|
180
372
|
if (checkVisitorRecordCeiling(used, maxPerHost).exceeded) return true;
|
|
181
373
|
}
|
|
182
374
|
/*
|
|
@@ -194,25 +386,54 @@ import { notifyHostManagers } from "./notifications.js";
|
|
|
194
386
|
* reader so "already consented" means the same thing here as it does
|
|
195
387
|
* at send time.
|
|
196
388
|
*/ /*
|
|
197
|
-
* THE
|
|
389
|
+
* THE SITE'S REAL GROUP, because the silo that justified a group of one
|
|
390
|
+
* is gone (AGL-3275).
|
|
391
|
+
*
|
|
392
|
+
* This read `soloConsentGroup(hostId)` for as long as a lead lived at
|
|
393
|
+
* `hosts/{hostId}/leads`, and the reason was the path: private by
|
|
394
|
+
* construction, so pooling a lead's basis would have recorded a
|
|
395
|
+
* disclosure that reached nothing, while the contact written by the
|
|
396
|
+
* same capture door was org-shared and pooled.
|
|
397
|
+
*
|
|
398
|
+
* A lead is now org-shared on exactly the terms the contact is, so the
|
|
399
|
+
* premise is false and keeping the group of one would leave a
|
|
400
|
+
* multi-brand org holding a lead its sibling brand can SEE but may not
|
|
401
|
+
* MAIL — a narrower basis than the one `consentGroupDisclosure` named
|
|
402
|
+
* beside the checkbox this person ticked. Pooling here records what
|
|
403
|
+
* they were actually told; an undeclared group is still a group of one,
|
|
404
|
+
* so an agency is unchanged and configures nothing.
|
|
198
405
|
*
|
|
199
|
-
*
|
|
200
|
-
|
|
201
|
-
* disclosure that reaches nothing. The contact written by the same
|
|
202
|
-
* capture door IS org-shared and IS pooled, which is where a declared
|
|
203
|
-
* group's disclosure is honored.
|
|
204
|
-
*/ const group = soloConsentGroup(hostId);
|
|
205
|
-
const prior = readMarketingBasis((_existing_data = existing.data()) != null ? _existing_data : null, group);
|
|
406
|
+
* Resolved above the transaction — see `group`'s declaration.
|
|
407
|
+
*/ const prior = readMarketingBasis((_existing_data = existing.data()) != null ? _existing_data : null, group);
|
|
206
408
|
const consentAtMs = prior.basis === 'granted' && prior.basisAtMs !== null ? prior.basisAtMs : now;
|
|
207
409
|
created = !existing.exists;
|
|
208
410
|
tx.set(leadRef, _extends({
|
|
209
411
|
email: lead.email
|
|
210
|
-
}, seen,
|
|
412
|
+
}, seen, {
|
|
413
|
+
/*
|
|
414
|
+
* WIDENED BY THE CAPTURE, NEVER BY THE LOOKUP (AGL-3275).
|
|
415
|
+
*
|
|
416
|
+
* `arrayUnion` rather than a replace, exactly as `upsert-contact`
|
|
417
|
+
* stamps a contact: this site just collected this person, so it may
|
|
418
|
+
* see the row. A site that merely FOUND them — the unscoped dedupe
|
|
419
|
+
* lookup in `readLeadForHost` — gains nothing, which is what keeps
|
|
420
|
+
* an agency's clients apart on a record they share.
|
|
421
|
+
*/ visibleTo: FieldValue.arrayUnion(...scope),
|
|
422
|
+
/*
|
|
423
|
+
* EVERY SITE THAT CAPTURED THIS PERSON, not just the first
|
|
424
|
+
* (AGL-3275).
|
|
425
|
+
*
|
|
426
|
+
* This was written once, on create, and that was sound while the
|
|
427
|
+
* collection sat under one host and could hold only that host's
|
|
428
|
+
* name. On the org the row is shared, so a sibling brand capturing
|
|
429
|
+
* a person the first brand already holds has to be recorded here or
|
|
430
|
+
* the "Known by" answer silently omits it — the same `arrayUnion`
|
|
431
|
+
* the contact door has always used for this field, now that a lead
|
|
432
|
+
* has the same question to answer.
|
|
433
|
+
*/ [CAPTURED_BY_HOST_FIELD]: FieldValue.arrayUnion(hostId)
|
|
434
|
+
}, existing.exists ? {} : {
|
|
211
435
|
firstSeenAtMs: now,
|
|
212
|
-
createdAt: FieldValue.serverTimestamp()
|
|
213
|
-
[CAPTURED_BY_HOST_FIELD]: [
|
|
214
|
-
hostId
|
|
215
|
-
]
|
|
436
|
+
createdAt: FieldValue.serverTimestamp()
|
|
216
437
|
}, lead.marketingConsent ? marketingConsentFieldsForGroup(group, consentAtMs) : {}), {
|
|
217
438
|
merge: true
|
|
218
439
|
});
|
|
@@ -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 soloConsentGroup,\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'\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 = hostRef.collection('leads')\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 const leadRef = key ? leadsRef.doc(key) : leadsRef.doc()\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 const used = (await tx.get(leadsRef.count())).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 GROUP OF ONE, and deliberately so on this silo.\n *\n * `hosts/{hostId}/leads` is private by path — no sibling site can sweep\n * it, declared group or not — so pooling a lead's basis would record a\n * disclosure that reaches nothing. The contact written by the same\n * capture door IS org-shared and IS pooled, which is where a declared\n * group's disclosure is honored.\n */\n const group = soloConsentGroup(hostId)\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 ...(existing.exists\n ? {}\n : {\n firstSeenAtMs: now,\n createdAt: FieldValue.serverTimestamp(),\n [CAPTURED_BY_HOST_FIELD]: [hostId],\n }),\n /*\n * Recorded under THIS host even though the collection already sits\n * under it.\n *\n * A lead cannot be swept by another site — `hosts/{hostId}/leads`\n * is private by path — so the host key adds no enforcement here.\n * It is written anyway because {@link readMarketingBasis} is one\n * function over four silos, and a silo whose basis lived somewhere\n * else would need the reader to know which collection it was\n * handed. A reader that has to be told the shape is a reader that\n * 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","soloConsentGroup","submissionMonthKey","visitorRecordRefusedCounterId","FieldValue","attributeCampaignConversion","notifyHostManagers","recordVisitorRecordCeilingTrip","options","hostRef","hostId","kind","ceiling","monthKey","refusedSnapshot","refusedRef","collection","doc","get","alreadyRefused","Number","set","increment","lastRefusedAtMs","Date","now","merge","leads","type","title","body","toLocaleString","link","error","console","addHostLead","lead","maxPerHost","leadsRef","firestore","key","email","leadRef","seen","sources","arrayUnion","source","lastSeenAtMs","submissionCount","name","created","refused","runTransaction","tx","existing","exists","used","count","data","exceeded","group","prior","consentAtMs","basis","basisAtMs","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\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/** The legacy home, for the read half of the migration window only. */\nfunction hostLeads(hostId: string): FirebaseFirestore.CollectionReference {\n return firestore().collection('hosts').doc(hostId).collection('leads')\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 if (orgRow.exists) return orgRow\n const hostRow = await hostLeads(hostId).doc(key).get()\n return hostRow.exists ? hostRow : null\n}\n\n/**\n * The ref a write should target, with any host-path predecessor already\n * carried onto it.\n *\n * A lead that has not been backfilled yet is copied to the org on the next\n * write that touches it, under the capturing site's scope, and the host row\n * is left where it is for AGL-3276 to archive. Two things follow: a write\n * never has to decide which of two rows it is amending, and no later read\n * can be answered by a host row that a write has since moved past.\n *\n * `carried` says whether this call did that copy, so a caller can log it and\n * the backfill's count can be reconciled against it.\n */\nexport async function leadForWrite(\n hostId: string,\n key: string,\n org?: Record<string, unknown> | null,\n): Promise<{\n ref: FirebaseFirestore.DocumentReference\n existed: boolean\n carried: boolean\n}> {\n const ref = (await orgLeadsForHost(hostId)).doc(key)\n if ((await ref.get()).exists) return { ref, existed: true, carried: false }\n\n const legacy = await hostLeads(hostId).doc(key).get()\n if (!legacy.exists) return { ref, existed: false, carried: false }\n\n // The legacy row carries no `visibleTo` — it was scoped by its parent — so\n // the capturing site's group supplies one. `set` rather than `create`: a\n // peer carrying the same row in the same second must not fail the write\n // that provoked it, and both writes carry identical field values.\n await ref.set(\n {\n ...(legacy.data() ?? {}),\n visibleTo: await leadScopeForHost(hostId, org),\n migratedFromHostId: hostId,\n },\n { merge: true },\n )\n return { ref, existed: true, carried: true }\n}\n\n/**\n * Whether a host-path lead survives for this address — AGL-3276 reconciling\n * itself, and AGL-3277's deletion check. Not a read path for product code,\n * which wants {@link readLeadForHost}.\n */\nexport async function legacyLeadExists(\n hostId: string,\n key: string,\n): Promise<boolean> {\n return (await hostLeads(hostId).doc(key).get()).exists\n}\n\n/** Every org id this process has resolved a lead for — the backfill's entry point. */\nexport async function orgIdForLeadHost(hostId: string): Promise<string | null> {\n return resolveOrgIdForHost(hostId)\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","resolveOrgIdForHost","scopedToHost","crmReadTokens","crmScopeTokens","firestore","app","hostLeads","hostId","collection","doc","orgLeadsForHost","orgLeadsQueryForHost","ref","query","leadReadTokensForHost","group","leadScopeForHost","org","readLeadForHost","key","orgRow","get","exists","hostRow","leadForWrite","legacy","existed","carried","set","data","visibleTo","migratedFromHostId","merge","legacyLeadExists","orgIdForLeadHost","recordVisitorRecordCeilingTrip","options","hostRef","kind","ceiling","monthKey","refusedSnapshot","refusedRef","alreadyRefused","Number","increment","lastRefusedAtMs","Date","now","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","exceeded","prior","consentAtMs","basis","basisAtMs","firstSeenAtMs","createdAt","serverTimestamp","marketingConsent","touch","refId","id","convertedAtMs"],"mappings":"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"}
|