@aglyn/tenant-data-admin 1.0.0-beta.199 → 1.0.0-beta.200
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -7
- package/src/lib/server/admin-audit-write.d.ts +28 -0
- package/src/lib/server/admin-audit-write.js +46 -0
- package/src/lib/server/admin-audit-write.js.map +1 -0
- package/src/lib/server/admin-audit.d.ts +4 -9
- package/src/lib/server/admin-audit.js +12 -31
- package/src/lib/server/admin-audit.js.map +1 -1
- package/src/lib/server/consent-group-carry.js +2 -0
- package/src/lib/server/consent-group-carry.js.map +1 -1
- package/src/lib/server/contact-company-link.js +3 -0
- package/src/lib/server/contact-company-link.js.map +1 -1
- package/src/lib/server/contact-lifecycle-floor.js +3 -0
- package/src/lib/server/contact-lifecycle-floor.js.map +1 -1
- package/src/lib/server/contact-merge.js +4 -0
- package/src/lib/server/contact-merge.js.map +1 -1
- package/src/lib/server/crm-booking-activity.js +3 -2
- package/src/lib/server/crm-booking-activity.js.map +1 -1
- package/src/lib/server/crm-records.d.ts +15 -0
- package/src/lib/server/crm-records.js +52 -0
- package/src/lib/server/crm-records.js.map +1 -1
- package/src/lib/server/csp-aggregate.d.ts +16 -0
- package/src/lib/server/csp-aggregate.js +36 -0
- package/src/lib/server/csp-aggregate.js.map +1 -1
- package/src/lib/server/duplicate-resource.js +12 -5
- package/src/lib/server/duplicate-resource.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +33 -0
- package/src/lib/server/email-delivery-log.js +43 -2
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-suppression.d.ts +11 -14
- package/src/lib/server/email-suppression.js +14 -31
- package/src/lib/server/email-suppression.js.map +1 -1
- package/src/lib/server/erase-person.js +22 -3
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/erase.js +4 -3
- package/src/lib/server/erase.js.map +1 -1
- package/src/lib/server/host-memberships.d.ts +34 -0
- package/src/lib/server/host-memberships.js +89 -1
- package/src/lib/server/host-memberships.js.map +1 -1
- package/src/lib/server/host-visitor-records.js +23 -3
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/list-members.d.ts +11 -0
- package/src/lib/server/list-members.js +28 -1
- package/src/lib/server/list-members.js.map +1 -1
- package/src/lib/server/list-query.d.ts +36 -0
- package/src/lib/server/list-query.js +49 -0
- package/src/lib/server/list-query.js.map +1 -0
- package/src/lib/server/media-tombstone.js +8 -2
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/org-member-list-fields.d.ts +31 -0
- package/src/lib/server/org-member-list-fields.js +38 -0
- package/src/lib/server/org-member-list-fields.js.map +1 -0
- package/src/lib/server/organizations.js +37 -7
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/replay-erasures.js +2 -1
- package/src/lib/server/replay-erasures.js.map +1 -1
- package/src/lib/server/sso-enforcement.js +2 -1
- package/src/lib/server/sso-enforcement.js.map +1 -1
- package/src/lib/server/upsert-contact.js +8 -4
- package/src/lib/server/upsert-contact.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-merge.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 * MERGING TWO CONTACTS — the write (AGL-2625).\n *\n * `planContactMerge` says what the survivor becomes; this performs it, and\n * moves everything that pointed at the merged record. One function behind\n * two doors — the console's `crm/contacts-merge` route and\n * `POST /v1/contacts/{id}/merge` — so the REST caller and the record page\n * cannot merge two different ways.\n *\n * ## The order is the crash-safety argument\n *\n * 1. Every deal, task and activity naming the merged contact is repointed\n * at the survivor, and every lead converted into it. Batched, bounded,\n * idempotent: a re-run finds nothing left to move.\n * 2. Then, in ONE transaction: the survivor takes the plan, every address\n * it now answers to is indexed at it, and the merged document is\n * deleted — with both records re-read inside the transaction, so two\n * merges racing on one pair cannot both succeed and a plan is never\n * computed on a document that changed under it.\n *\n * Children first because a process that dies between the two steps leaves\n * children pointing at a record that EXISTS — the survivor — while the merged\n * document is still there for a retry to fold in. The other order would\n * leave deals pointing at a deleted contact, which is a record nobody can\n * open from anywhere.\n *\n * ## What it does not move\n *\n * List memberships, suppression entries and the delivery log are keyed by\n * ADDRESS, not by contact id, and stay under the merged address; a capture\n * on that address resolves to the survivor through the index, which is the\n * fact those keys need. A company's contacts count drops only for a company\n * both records named — the survivor still names the rest.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n contactEmails,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter'\nimport { planContactMerge } from '@aglyn/aglyn/app-utils/contact-merge'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { settleCompanyContactsCounts } from './contact-company-link'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport { logHostActivity } from './organizations'\n\n/** How many pointing rows one repoint pass reads — and one batch writes. */\nconst REPOINT_PAGE = 400\n/** A ceiling on passes, so a query that keeps answering cannot loop forever. */\nconst REPOINT_PASSES = 25\n\nexport interface MergeContactsOptions {\n firestore: FirebaseFirestore.Firestore\n /** `orgs/{orgId}` — the org whose contacts both records are. */\n orgRef: FirebaseFirestore.DocumentReference\n /** The record that stays, and keeps its address as the identity. */\n survivorId: string\n /** The record folded in and deleted. */\n mergedId: string\n actor: HostActivityActor\n /**\n * The site whose console did the work: where the activity feed entry\n * lands, and the site a timeline note is filed under. `null` for a door\n * with no site — the REST API — which logs no host entry.\n */\n hostId: string | null\n /** The actor's display name for the timeline note, when the door knows it. */\n actorName?: string | null\n}\n\nexport interface MergeContactsRepointed {\n deals: number\n tasks: number\n activities: number\n leads: number\n}\n\nexport type MergeContactsResult =\n | {\n ok: true\n survivorId: string\n survivorEmail: string\n mergedId: string\n mergedEmail: string\n /** Every address the survivor answers to now, primary first. */\n emails: string[]\n repointed: MergeContactsRepointed\n }\n | { ok: false; reason: 'same-record' | 'survivor-missing' | 'merged-missing' }\n\n/**\n * Point every row of `collection` whose `contactId` is `from` at `to`.\n *\n * Page, batch, repeat: each pass reads the rows still pointing at the\n * merged record — the ones a previous pass moved no longer match — so the\n * loop ends when a pass finds nothing. `updatedAt` moves with the pointer\n * because an integration syncing by `updatedAfter` has to see the change.\n */\nasync function repointContactId(\n firestore: FirebaseFirestore.Firestore,\n collection: FirebaseFirestore.CollectionReference,\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n for (let pass = 0; pass < REPOINT_PASSES; pass += 1) {\n const page = await collection\n .where('contactId', '==', from)\n .limit(REPOINT_PAGE)\n .get()\n if (page.empty) break\n const batch = firestore.batch()\n for (const row of page.docs) {\n batch.update(row.ref, {\n contactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n moved += page.size\n if (page.size < REPOINT_PAGE) break\n }\n return moved\n}\n\n/**\n * The leads converted into the merged contact, restamped at the survivor.\n *\n * A lead lives at `orgs/{orgId}/leads/{personKey(email)}` (AGL-3275), so there\n * is still no query — one document per address, asked for directly. This used\n * to be bounded by sites TIMES addresses because a lead lived under each site;\n * it is bounded by addresses now, and the sites drop out of the signature\n * because the collection no longer has one per site to walk.\n */\nasync function repointLeads(\n orgRef: FirebaseFirestore.DocumentReference,\n emails: readonly string[],\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n /*\n * ONE ROW PER ADDRESS, on the org (AGL-3275). This walked the sites because\n * there was a lead under each of them; re-pointing the same document once\n * per site would now be the same write repeated, and the second would find\n * `convertedContactId` already moved and skip — a silent undercount rather\n * than a wrong answer, but an undercount the caller reports to the operator.\n */\n for (const email of emails) {\n const key = personKey(email)\n if (!key) continue\n const leadRef = orgRef.collection('leads').doc(key)\n const lead = await leadRef.get()\n if (!lead.exists || lead.get('convertedContactId') !== from) continue\n await leadRef.update({\n convertedContactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n moved += 1\n }\n return moved\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((item): item is string => typeof item === 'string' && item !== '')\n : []\n}\n\nexport async function mergeContacts(\n options: MergeContactsOptions,\n): Promise<MergeContactsResult> {\n const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options\n if (!survivorId || !mergedId || survivorId === mergedId) {\n return { ok: false, reason: 'same-record' }\n }\n const contactsRef = orgRef.collection('contacts')\n const survivorRef = contactsRef.doc(survivorId)\n const mergedRef = contactsRef.doc(mergedId)\n const [survivorBefore, mergedBefore] = await Promise.all([\n survivorRef.get(),\n mergedRef.get(),\n ])\n if (!survivorBefore.exists) return { ok: false, reason: 'survivor-missing' }\n if (!mergedBefore.exists) return { ok: false, reason: 'merged-missing' }\n const mergedData = (mergedBefore.data() ?? {}) as Record<string, unknown>\n const survivorData = (survivorBefore.data() ?? {}) as Record<string, unknown>\n\n /*==========================================\n * 1. THE CHILDREN — before the parent, for the reason the header gives.\n *=========================================*/\n const [deals, tasks, activities] = await Promise.all([\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),\n repointContactId(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.activities),\n mergedId,\n survivorId,\n ),\n ])\n const leadHosts = [\n ...new Set([\n ...strings(mergedData['capturedByHostIds']),\n ...strings(survivorData['capturedByHostIds']),\n ...strings([mergedData['hostId'], survivorData['hostId'], hostId]),\n ]),\n ]\n const leads = await repointLeads(\n orgRef,\n contactEmails(mergedData),\n mergedId,\n survivorId,\n )\n\n /*==========================================\n * 2. THE SWAP — one transaction over both documents and the index.\n *=========================================*/\n const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION)\n const swapped = await firestore.runTransaction(async (transaction) => {\n const [survivor, merged] = await Promise.all([\n transaction.get(survivorRef),\n transaction.get(mergedRef),\n ])\n if (!survivor.exists) return { reason: 'survivor-missing' as const }\n if (!merged.exists) return { reason: 'merged-missing' as const }\n const plan = planContactMerge(\n (survivor.data() ?? {}) as Record<string, unknown>,\n (merged.data() ?? {}) as Record<string, unknown>,\n )\n transaction.set(\n survivorRef,\n { ...plan.survivor, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n for (const email of plan.emails) {\n const key = personKey(email)\n if (!key) continue\n transaction.set(\n index.doc(key),\n { email, contactId: survivorId, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n transaction.delete(mergedRef)\n return { plan, survivor: (survivor.data() ?? {}) as Record<string, unknown> }\n })\n if ('reason' in swapped) return { ok: false, reason: swapped.reason }\n const { plan } = swapped\n\n /*\n * The companies both records named counted two people and now count one\n * (AGL-2613). Settled after the swap the way the capture settles its\n * counts: a figure that could not move is logged, and the company page's\n * live aggregate corrects it.\n */\n await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {\n companyId: null,\n mirror: null,\n counts: plan.companyCounts,\n })\n /*\n * The survivor now owns the merged record's open tasks too, so its next\n * activity is the earliest of both (AGL-2661). Settled the same way: a\n * figure that could not move is logged, and the Fields section's\n * recompute corrects it.\n */\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [{ contactId: survivorId }]).catch(\n (error: unknown) => {\n console.error('contact merge next activity could not be recomputed', survivorId, error)\n },\n )\n\n const mergedEmail = contactEmails(mergedData)[0] ?? ''\n const survivorEmail = plan.emails[0] ?? ''\n // The canonical name — the one identity every holder shares — for the\n // feed; the address stands in for a person nobody has named.\n const survivorName =\n typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : ''\n\n /*\n * THE TIMELINE NOTE, on the survivor: the merge is an event in this\n * person's history, and the record page is where a reader looks for one.\n * Filed under the calling site, or the survivor's first site for a door\n * with none; its own catch, because a note that could not be written must\n * not read as a merge that did not happen.\n */\n try {\n await orgRef.collection(CRM_COLLECTIONS.activities).add({\n kind: 'note',\n body: `Merged with ${mergedEmail}`,\n atMs: Date.now(),\n byUid: actor.uid,\n ...(options.actorName || actor.email\n ? { byName: options.actorName || actor.email }\n : {}),\n contactId: survivorId,\n visibleTo: plan.visibleTo,\n hostId: hostId ?? String(survivorData['hostId'] ?? ''),\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('[contact-merge] timeline note failed', survivorId, error)\n }\n\n // The audit line (AGL-2622), in the feed of the site whose console did\n // the work. A door with no site — the API — has no host feed to write.\n if (hostId) {\n await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {\n type: 'contact',\n id: survivorId,\n name: survivorName || survivorEmail,\n })\n }\n\n return {\n ok: true,\n survivorId,\n survivorEmail,\n mergedId,\n mergedEmail,\n emails: plan.emails,\n repointed: { deals, tasks, activities, leads },\n }\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","planContactMerge","CRM_COLLECTIONS","personKey","FieldValue","settleCompanyContactsCounts","recomputeCrmNextTaskAt","logHostActivity","REPOINT_PAGE","REPOINT_PASSES","repointContactId","firestore","collection","from","to","moved","pass","page","where","limit","get","empty","batch","row","docs","update","ref","contactId","updatedAt","serverTimestamp","commit","size","repointLeads","orgRef","emails","email","key","leadRef","doc","lead","exists","convertedContactId","strings","value","Array","isArray","filter","item","mergeContacts","options","mergedBefore","survivorBefore","plan","survivorId","mergedId","actor","hostId","ok","reason","contactsRef","survivorRef","mergedRef","Promise","all","mergedData","data","survivorData","deals","tasks","activities","leadHosts","Set","leads","index","swapped","runTransaction","transaction","survivor","merged","set","merge","delete","companies","companyId","mirror","counts","companyCounts","id","catch","error","console","mergedEmail","survivorEmail","survivorName","add","kind","body","atMs","Date","now","byUid","uid","actorName","byName","visibleTo","String","createdByUid","createdAt","type","name","repointed"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-merge.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 * MERGING TWO CONTACTS — the write (AGL-2625).\n *\n * `planContactMerge` says what the survivor becomes; this performs it, and\n * moves everything that pointed at the merged record. One function behind\n * two doors — the console's `crm/contacts-merge` route and\n * `POST /v1/contacts/{id}/merge` — so the REST caller and the record page\n * cannot merge two different ways.\n *\n * ## The order is the crash-safety argument\n *\n * 1. Every deal, task and activity naming the merged contact is repointed\n * at the survivor, and every lead converted into it. Batched, bounded,\n * idempotent: a re-run finds nothing left to move.\n * 2. Then, in ONE transaction: the survivor takes the plan, every address\n * it now answers to is indexed at it, and the merged document is\n * deleted — with both records re-read inside the transaction, so two\n * merges racing on one pair cannot both succeed and a plan is never\n * computed on a document that changed under it.\n *\n * Children first because a process that dies between the two steps leaves\n * children pointing at a record that EXISTS — the survivor — while the merged\n * document is still there for a retry to fold in. The other order would\n * leave deals pointing at a deleted contact, which is a record nobody can\n * open from anywhere.\n *\n * ## What it does not move\n *\n * List memberships, suppression entries and the delivery log are keyed by\n * ADDRESS, not by contact id, and stay under the merged address; a capture\n * on that address resolves to the survivor through the index, which is the\n * fact those keys need. A company's contacts count drops only for a company\n * both records named — the survivor still names the rest.\n */\n\nimport {\n CONTACT_EMAIL_INDEX_COLLECTION,\n contactEmails,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport type { HostActivityActor } from '@aglyn/aglyn/app-utils/activity-presenter'\nimport { planContactMerge } from '@aglyn/aglyn/app-utils/contact-merge'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { personKey } from '@aglyn/aglyn/app-utils/person-key'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { settleCompanyContactsCounts } from './contact-company-link'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport { restampCrmListFieldsAt } from './crm-records'\nimport { logHostActivity } from './organizations'\n\n/** How many pointing rows one repoint pass reads — and one batch writes. */\nconst REPOINT_PAGE = 400\n/** A ceiling on passes, so a query that keeps answering cannot loop forever. */\nconst REPOINT_PASSES = 25\n\nexport interface MergeContactsOptions {\n firestore: FirebaseFirestore.Firestore\n /** `orgs/{orgId}` — the org whose contacts both records are. */\n orgRef: FirebaseFirestore.DocumentReference\n /** The record that stays, and keeps its address as the identity. */\n survivorId: string\n /** The record folded in and deleted. */\n mergedId: string\n actor: HostActivityActor\n /**\n * The site whose console did the work: where the activity feed entry\n * lands, and the site a timeline note is filed under. `null` for a door\n * with no site — the REST API — which logs no host entry.\n */\n hostId: string | null\n /** The actor's display name for the timeline note, when the door knows it. */\n actorName?: string | null\n}\n\nexport interface MergeContactsRepointed {\n deals: number\n tasks: number\n activities: number\n leads: number\n}\n\nexport type MergeContactsResult =\n | {\n ok: true\n survivorId: string\n survivorEmail: string\n mergedId: string\n mergedEmail: string\n /** Every address the survivor answers to now, primary first. */\n emails: string[]\n repointed: MergeContactsRepointed\n }\n | { ok: false; reason: 'same-record' | 'survivor-missing' | 'merged-missing' }\n\n/**\n * Point every row of `collection` whose `contactId` is `from` at `to`.\n *\n * Page, batch, repeat: each pass reads the rows still pointing at the\n * merged record — the ones a previous pass moved no longer match — so the\n * loop ends when a pass finds nothing. `updatedAt` moves with the pointer\n * because an integration syncing by `updatedAfter` has to see the change.\n */\nasync function repointContactId(\n firestore: FirebaseFirestore.Firestore,\n collection: FirebaseFirestore.CollectionReference,\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n for (let pass = 0; pass < REPOINT_PASSES; pass += 1) {\n const page = await collection\n .where('contactId', '==', from)\n .limit(REPOINT_PAGE)\n .get()\n if (page.empty) break\n const batch = firestore.batch()\n for (const row of page.docs) {\n batch.update(row.ref, {\n contactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n await batch.commit()\n moved += page.size\n if (page.size < REPOINT_PAGE) break\n }\n return moved\n}\n\n/**\n * The leads converted into the merged contact, restamped at the survivor.\n *\n * A lead lives at `orgs/{orgId}/leads/{personKey(email)}` (AGL-3275), so there\n * is still no query — one document per address, asked for directly. This used\n * to be bounded by sites TIMES addresses because a lead lived under each site;\n * it is bounded by addresses now, and the sites drop out of the signature\n * because the collection no longer has one per site to walk.\n */\nasync function repointLeads(\n orgRef: FirebaseFirestore.DocumentReference,\n emails: readonly string[],\n from: string,\n to: string,\n): Promise<number> {\n let moved = 0\n /*\n * ONE ROW PER ADDRESS, on the org (AGL-3275). This walked the sites because\n * there was a lead under each of them; re-pointing the same document once\n * per site would now be the same write repeated, and the second would find\n * `convertedContactId` already moved and skip — a silent undercount rather\n * than a wrong answer, but an undercount the caller reports to the operator.\n */\n for (const email of emails) {\n const key = personKey(email)\n if (!key) continue\n const leadRef = orgRef.collection('leads').doc(key)\n const lead = await leadRef.get()\n if (!lead.exists || lead.get('convertedContactId') !== from) continue\n await leadRef.update({\n convertedContactId: to,\n updatedAt: FieldValue.serverTimestamp(),\n })\n moved += 1\n }\n return moved\n}\n\nfunction strings(value: unknown): string[] {\n return Array.isArray(value)\n ? value.filter((item): item is string => typeof item === 'string' && item !== '')\n : []\n}\n\nexport async function mergeContacts(\n options: MergeContactsOptions,\n): Promise<MergeContactsResult> {\n const { firestore, orgRef, survivorId, mergedId, actor, hostId } = options\n if (!survivorId || !mergedId || survivorId === mergedId) {\n return { ok: false, reason: 'same-record' }\n }\n const contactsRef = orgRef.collection('contacts')\n const survivorRef = contactsRef.doc(survivorId)\n const mergedRef = contactsRef.doc(mergedId)\n const [survivorBefore, mergedBefore] = await Promise.all([\n survivorRef.get(),\n mergedRef.get(),\n ])\n if (!survivorBefore.exists) return { ok: false, reason: 'survivor-missing' }\n if (!mergedBefore.exists) return { ok: false, reason: 'merged-missing' }\n const mergedData = (mergedBefore.data() ?? {}) as Record<string, unknown>\n const survivorData = (survivorBefore.data() ?? {}) as Record<string, unknown>\n\n /*==========================================\n * 1. THE CHILDREN — before the parent, for the reason the header gives.\n *=========================================*/\n const [deals, tasks, activities] = await Promise.all([\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.deals), mergedId, survivorId),\n repointContactId(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), mergedId, survivorId),\n repointContactId(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.activities),\n mergedId,\n survivorId,\n ),\n ])\n const leadHosts = [\n ...new Set([\n ...strings(mergedData['capturedByHostIds']),\n ...strings(survivorData['capturedByHostIds']),\n ...strings([mergedData['hostId'], survivorData['hostId'], hostId]),\n ]),\n ]\n const leads = await repointLeads(\n orgRef,\n contactEmails(mergedData),\n mergedId,\n survivorId,\n )\n\n /*==========================================\n * 2. THE SWAP — one transaction over both documents and the index.\n *=========================================*/\n const index = orgRef.collection(CONTACT_EMAIL_INDEX_COLLECTION)\n const swapped = await firestore.runTransaction(async (transaction) => {\n const [survivor, merged] = await Promise.all([\n transaction.get(survivorRef),\n transaction.get(mergedRef),\n ])\n if (!survivor.exists) return { reason: 'survivor-missing' as const }\n if (!merged.exists) return { reason: 'merged-missing' as const }\n const plan = planContactMerge(\n (survivor.data() ?? {}) as Record<string, unknown>,\n (merged.data() ?? {}) as Record<string, unknown>,\n )\n transaction.set(\n survivorRef,\n { ...plan.survivor, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n for (const email of plan.emails) {\n const key = personKey(email)\n if (!key) continue\n transaction.set(\n index.doc(key),\n { email, contactId: survivorId, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n }\n transaction.delete(mergedRef)\n return { plan, survivor: (survivor.data() ?? {}) as Record<string, unknown> }\n })\n if ('reason' in swapped) return { ok: false, reason: swapped.reason }\n const { plan } = swapped\n // The survivor now carries both records' addresses, holders and facets —\n // what the Contacts list searches and filters by (AGL-3321).\n await restampCrmListFieldsAt(survivorRef, 'contacts')\n\n /*\n * The companies both records named counted two people and now count one\n * (AGL-2613). Settled after the swap the way the capture settles its\n * counts: a figure that could not move is logged, and the company page's\n * live aggregate corrects it.\n */\n await settleCompanyContactsCounts(orgRef.collection(CRM_COLLECTIONS.companies), {\n companyId: null,\n mirror: null,\n counts: plan.companyCounts,\n })\n /*\n * The survivor now owns the merged record's open tasks too, so its next\n * activity is the earliest of both (AGL-2661). Settled the same way: a\n * figure that could not move is logged, and the Fields section's\n * recompute corrects it.\n */\n await recomputeCrmNextTaskAt(firestore, orgRef.id, [{ contactId: survivorId }]).catch(\n (error: unknown) => {\n console.error('contact merge next activity could not be recomputed', survivorId, error)\n },\n )\n\n const mergedEmail = contactEmails(mergedData)[0] ?? ''\n const survivorEmail = plan.emails[0] ?? ''\n // The canonical name — the one identity every holder shares — for the\n // feed; the address stands in for a person nobody has named.\n const survivorName =\n typeof swapped.survivor['name'] === 'string' ? swapped.survivor['name'] : ''\n\n /*\n * THE TIMELINE NOTE, on the survivor: the merge is an event in this\n * person's history, and the record page is where a reader looks for one.\n * Filed under the calling site, or the survivor's first site for a door\n * with none; its own catch, because a note that could not be written must\n * not read as a merge that did not happen.\n */\n try {\n await orgRef.collection(CRM_COLLECTIONS.activities).add({\n kind: 'note',\n body: `Merged with ${mergedEmail}`,\n atMs: Date.now(),\n byUid: actor.uid,\n ...(options.actorName || actor.email\n ? { byName: options.actorName || actor.email }\n : {}),\n contactId: survivorId,\n visibleTo: plan.visibleTo,\n hostId: hostId ?? String(survivorData['hostId'] ?? ''),\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n } catch (error) {\n console.error('[contact-merge] timeline note failed', survivorId, error)\n }\n\n // The audit line (AGL-2622), in the feed of the site whose console did\n // the work. A door with no site — the API — has no host feed to write.\n if (hostId) {\n await logHostActivity(hostId, actor, `Merged with ${mergedEmail}`, {\n type: 'contact',\n id: survivorId,\n name: survivorName || survivorEmail,\n })\n }\n\n return {\n ok: true,\n survivorId,\n survivorEmail,\n mergedId,\n mergedEmail,\n emails: plan.emails,\n repointed: { deals, tasks, activities, leads },\n }\n}\n"],"names":["CONTACT_EMAIL_INDEX_COLLECTION","contactEmails","planContactMerge","CRM_COLLECTIONS","personKey","FieldValue","settleCompanyContactsCounts","recomputeCrmNextTaskAt","restampCrmListFieldsAt","logHostActivity","REPOINT_PAGE","REPOINT_PASSES","repointContactId","firestore","collection","from","to","moved","pass","page","where","limit","get","empty","batch","row","docs","update","ref","contactId","updatedAt","serverTimestamp","commit","size","repointLeads","orgRef","emails","email","key","leadRef","doc","lead","exists","convertedContactId","strings","value","Array","isArray","filter","item","mergeContacts","options","mergedBefore","survivorBefore","plan","survivorId","mergedId","actor","hostId","ok","reason","contactsRef","survivorRef","mergedRef","Promise","all","mergedData","data","survivorData","deals","tasks","activities","leadHosts","Set","leads","index","swapped","runTransaction","transaction","survivor","merged","set","merge","delete","companies","companyId","mirror","counts","companyCounts","id","catch","error","console","mergedEmail","survivorEmail","survivorName","add","kind","body","atMs","Date","now","byUid","uid","actorName","byName","visibleTo","String","createdByUid","createdAt","type","name","repointed"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import { checkEntitlement, consentGroupForHost, CRM_COLLECTIONS, crmActivityLogHasRoom, crmBookingFollowUpDueMs, crmBookingFollowUpTitle, crmBookingMeetingBody, crmScopeTokens, isHostPluginEnabled, parseCrmBookingRef, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
|
|
17
|
+
*/ import { checkEntitlement, consentGroupForHost, CRM_COLLECTIONS, crmTaskListFields, crmActivityLogHasRoom, crmBookingFollowUpDueMs, crmBookingFollowUpTitle, crmBookingMeetingBody, crmScopeTokens, isHostPluginEnabled, parseCrmBookingRef, readContactFacet, visibleToHost } from "@aglyn/aglyn/server";
|
|
18
18
|
import { FieldValue } from "firebase-admin/firestore";
|
|
19
19
|
import { findContactByEmail } from "./contact-email-index.js";
|
|
20
20
|
import { readLeadForHost } from "./host-visitor-records.js";
|
|
@@ -234,7 +234,8 @@ import { countCrmActivitiesForRecord } from "./crm-records.js";
|
|
|
234
234
|
createdAt: FieldValue.serverTimestamp(),
|
|
235
235
|
updatedAt: FieldValue.serverTimestamp()
|
|
236
236
|
});
|
|
237
|
-
|
|
237
|
+
// What the Tasks list searches by (AGL-3321), beside the task.
|
|
238
|
+
const written = await orgRef.collection(CRM_COLLECTIONS.tasks).add(_extends({}, task, crmTaskListFields(task)));
|
|
238
239
|
taskId = written.id;
|
|
239
240
|
}
|
|
240
241
|
return {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-booking-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_COLLECTIONS,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n crmActivityLogHasRoom,\n crmBookingFollowUpDueMs,\n crmBookingFollowUpTitle,\n crmBookingMeetingBody,\n crmScopeTokens,\n isHostPluginEnabled,\n parseCrmBookingRef,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findContactByEmail } from './contact-email-index'\nimport { readLeadForHost } from './host-visitor-records'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * WHAT A BOOKING FILES ON THE CRM RECORD (AGL-2660).\n *\n * A confirmed booking is a meeting the record has on its calendar, and the\n * timeline should say so without a rep logging it by hand. So once a\n * booking's row is written — at creation for a free service, on payment\n * for a paid one — the record is found and a `meeting` activity filed under\n * it, with a follow-up task beside it when the service asks for one.\n *\n * ## Which record\n *\n * The reference the booking link carried wins: `crmRef` names the contact,\n * lead or deal the rep dropped the link from, and a booking made through\n * that link belongs to that record even when the booker typed a different\n * address. Without one — a booking taken off the widget cold — the booker's\n * address is looked up through the org's address index, narrowed to this\n * site, exactly as the automation steps resolve an event's contact. A\n * reference to a record the site cannot see, or that no longer exists,\n * falls through to the address rather than filing under nobody.\n *\n * ## Where it lives\n *\n * In the admin library rather than the Bookings plugin, because the row is\n * a CRM record: its scope, its ceiling and its collections are the CRM's,\n * and a plugin that stamped its own would be a second creator to keep in\n * step with `crmScopeTokens`. The Bookings plugin passes what it already\n * holds and reads the outcome; the CRM plugin is never imported.\n *\n * Firestore is a parameter, as in `crm-records.ts`: the callers hold one,\n * and a module that reached for the Admin app itself would drag it into\n * every spec that only wants the arithmetic.\n *\n * ## Failure posture\n *\n * **Never throws.** A booking that could not be filed on the CRM is still\n * a booking — the guest has the slot and the confirmation — so a failure\n * here is logged and reported in the outcome, never raised into the\n * request that took the booking or the webhook that confirmed it.\n */\n\n/** The CRM plugin's id, as `enabledPlugins` and `disabledPlugins` name it. */\nconst CRM_PLUGIN_ID = 'crm'\n\nexport interface CrmBookingRecordInput {\n hostId: string\n /**\n * The owning org, as `getOrgForHost` returns it — the plugin set, the\n * consent groups and the scope default are all read off it.\n */\n org: Record<string, unknown> | null | undefined\n orgId: string\n /** The booking as its row was written. */\n booking: {\n id: string\n serviceId?: string\n serviceName?: string\n email?: string\n startsAtMs: number\n endsAtMs: number\n /** The `crm` query value the link carried, kept on the row. */\n crmRef?: string | null\n }\n /** The service as stored: its two CRM switches and its timezone. */\n service:\n | {\n name?: string\n timezone?: string\n crmFollowUpTask?: boolean\n crmMeetingActivity?: boolean\n }\n | null\n | undefined\n /**\n * The host document's plugin fields, when the caller already holds them;\n * read here otherwise. The deny-list is per site, and a site that has\n * switched the CRM off must file nothing.\n */\n host?: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null\n}\n\nexport type CrmBookingRecordOutcome =\n | {\n filed: false\n reason:\n /** The CRM plugin does not run on this site. */\n | 'crm-off'\n /**\n * The org's plan does not carry the CRM suite, whose activities and\n * tasks these are (AGL-2787).\n */\n | 'not-entitled'\n /** Neither the reference nor the address named a record. */\n | 'no-record'\n /** A meeting for this booking is already on the timeline. */\n | 'already-filed'\n /** The service asks for neither a meeting nor a follow-up. */\n | 'nothing-to-file'\n /** A read or a write threw; logged, and nothing is promised. */\n | 'failed'\n }\n | {\n filed: true\n /** How the record was found. */\n matchedBy: 'crmRef' | 'email'\n link: CrmActivityLink\n /** The meeting's id, or `null` when the service files no meeting. */\n activityId: string | null\n /** The follow-up's id, or `null` when the service asks for none. */\n taskId: string | null\n }\n\ninterface ResolvedRecord {\n link: CrmActivityLink\n matchedBy: 'crmRef' | 'email'\n /** Whoever holds the relationship, for the follow-up's assignee. */\n ownerUid?: string\n}\n\n/**\n * The record a booking belongs to — the reference first, the address second.\n */\nasync function resolveBookingRecord(\n firestore: FirebaseFirestore.Firestore,\n input: CrmBookingRecordInput,\n groupId: string,\n): Promise<ResolvedRecord | null> {\n const { hostId, orgId } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contactsRef = orgRef.collection('contacts')\n const ref = parseCrmBookingRef(input.booking.crmRef)\n if (ref?.kind === 'contact') {\n const contact = await contactsRef.doc(ref.id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: {\n contactId: contact.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n },\n matchedBy: 'crmRef',\n ...(facet.ownerUid ? { ownerUid: facet.ownerUid } : {}),\n }\n }\n }\n if (ref?.kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(ref.id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: {\n dealId: deal.id,\n ...(contactId ? { contactId } : {}),\n ...(companyId ? { companyId } : {}),\n },\n matchedBy: 'crmRef',\n ...(ownerUid ? { ownerUid } : {}),\n }\n }\n }\n if (ref?.kind === 'lead') {\n /*\n * A lead carries its own `visibleTo` now (AGL-3275) — it used to be\n * host-scoped by path, and the comment here said so. The seam reads the\n * org row, and the site has to be allowed to see it before a booking is\n * filed against it, or one brand's booking would name another's lead.\n */\n const lead = await readLeadForHost(hostId, ref.id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return {\n link: { leadId: lead.id },\n matchedBy: 'crmRef',\n ...(ownerUid ? { ownerUid } : {}),\n }\n }\n }\n const hit = await findContactByEmail(contactsRef, input.booking.email, { hostId })\n if (!hit) return null\n const facet = readContactFacet(hit.data() ?? {}, groupId)\n return {\n link: {\n contactId: hit.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n },\n matchedBy: 'email',\n ...(facet.ownerUid ? { ownerUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * Files what a confirmed booking owes the CRM: the meeting, and the\n * follow-up when the service asks for one. See the module comment.\n */\nexport async function recordCrmBooking(\n firestore: FirebaseFirestore.Firestore,\n input: CrmBookingRecordInput,\n): Promise<CrmBookingRecordOutcome> {\n const { hostId, orgId, booking, service } = input\n try {\n if (!hostId || !orgId || !booking?.id) return { filed: false, reason: 'failed' }\n const wantsMeeting = service?.crmMeetingActivity !== false\n const wantsTask = service?.crmFollowUpTask === true\n if (!wantsMeeting && !wantsTask) return { filed: false, reason: 'nothing-to-file' }\n\n const host =\n input.host === undefined\n ? ((await firestore.collection('hosts').doc(hostId).get()).data() as\n | { disabledPlugins?: string[]; enabledPlugins?: string[] }\n | undefined) ?? null\n : input.host\n if (!isHostPluginEnabled(input.org ?? null, host, CRM_PLUGIN_ID)) {\n return { filed: false, reason: 'crm-off' }\n }\n if (!checkEntitlement(input.org as Parameters<typeof checkEntitlement>[0], 'crm')) {\n return { filed: false, reason: 'not-entitled' }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n // One meeting per booking, whatever path re-enters: the paid webhook is\n // redelivered for days after any 500, and its own guard is the status,\n // which this row is not part of.\n const already = await activities.where('bookingId', '==', booking.id).limit(1).get()\n if (!already.empty) return { filed: false, reason: 'already-filed' }\n\n const group = consentGroupForHost(input.org ?? null, hostId)\n const record = await resolveBookingRecord(firestore, input, group.groupId)\n if (!record) return { filed: false, reason: 'no-record' }\n\n const visibleTo = crmScopeTokens(input.org ?? null, group)\n const serviceName = String(service?.name ?? booking.serviceName ?? '').trim()\n const timezone = service?.timezone || undefined\n\n let activityId: string | null = null\n if (wantsMeeting) {\n // The per-record ceiling (AGL-2611): a full log refuses the meeting\n // but not the follow-up — a task is owed either way.\n const logged = await countCrmActivitiesForRecord(orgRef, record.link)\n if (crmActivityLogHasRoom(logged)) {\n const activity: CrmActivity = {\n kind: 'meeting',\n body: crmBookingMeetingBody({\n serviceName,\n startsAtMs: booking.startsAtMs,\n timezone,\n }),\n // When it HAPPENS, which is the slot — not when it was booked.\n atMs: booking.startsAtMs,\n byUid: '',\n ...record.link,\n bookingId: booking.id,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const written = await activities.add(activity)\n activityId = written.id\n }\n }\n\n let taskId: string | null = null\n if (wantsTask) {\n const task: CrmTask = {\n title: crmBookingFollowUpTitle(serviceName),\n kind: 'todo',\n priority: 'normal',\n status: 'open',\n dueAtMs: crmBookingFollowUpDueMs(booking.endsAtMs, timezone),\n ...(record.ownerUid ? { assigneeUid: record.ownerUid } : {}),\n createdByUid: '',\n ...record.link,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const written = await orgRef.collection(CRM_COLLECTIONS.tasks).add(task)\n taskId = written.id\n }\n\n return { filed: true, matchedBy: record.matchedBy, link: record.link, activityId, taskId }\n } catch (error) {\n console.error('[crm] booking could not be filed', hostId, booking?.id, error)\n return { filed: false, reason: 'failed' }\n }\n}\n"],"names":["checkEntitlement","consentGroupForHost","CRM_COLLECTIONS","crmActivityLogHasRoom","crmBookingFollowUpDueMs","crmBookingFollowUpTitle","crmBookingMeetingBody","crmScopeTokens","isHostPluginEnabled","parseCrmBookingRef","readContactFacet","visibleToHost","FieldValue","findContactByEmail","readLeadForHost","countCrmActivitiesForRecord","CRM_PLUGIN_ID","resolveBookingRecord","firestore","input","groupId","hit","hostId","orgId","orgRef","collection","doc","contactsRef","ref","booking","crmRef","kind","contact","id","get","exists","facet","data","link","contactId","companyId","matchedBy","ownerUid","deal","deals","String","dealId","lead","leadId","email","recordCrmBooking","service","filed","reason","wantsMeeting","crmMeetingActivity","wantsTask","crmFollowUpTask","host","undefined","org","activities","already","where","limit","empty","group","record","visibleTo","serviceName","name","trim","timezone","activityId","logged","activity","body","startsAtMs","atMs","byUid","bookingId","createdAt","serverTimestamp","updatedAt","written","add","taskId","task","title","priority","status","dueAtMs","endsAtMs","assigneeUid","createdByUid","tasks","error","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-booking-activity.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n checkEntitlement,\n consentGroupForHost,\n CRM_COLLECTIONS,\n crmTaskListFields,\n type CrmActivity,\n type CrmActivityLink,\n type CrmTask,\n crmActivityLogHasRoom,\n crmBookingFollowUpDueMs,\n crmBookingFollowUpTitle,\n crmBookingMeetingBody,\n crmScopeTokens,\n isHostPluginEnabled,\n parseCrmBookingRef,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { findContactByEmail } from './contact-email-index'\nimport { readLeadForHost } from './host-visitor-records'\nimport { countCrmActivitiesForRecord } from './crm-records'\n\n/**\n * WHAT A BOOKING FILES ON THE CRM RECORD (AGL-2660).\n *\n * A confirmed booking is a meeting the record has on its calendar, and the\n * timeline should say so without a rep logging it by hand. So once a\n * booking's row is written — at creation for a free service, on payment\n * for a paid one — the record is found and a `meeting` activity filed under\n * it, with a follow-up task beside it when the service asks for one.\n *\n * ## Which record\n *\n * The reference the booking link carried wins: `crmRef` names the contact,\n * lead or deal the rep dropped the link from, and a booking made through\n * that link belongs to that record even when the booker typed a different\n * address. Without one — a booking taken off the widget cold — the booker's\n * address is looked up through the org's address index, narrowed to this\n * site, exactly as the automation steps resolve an event's contact. A\n * reference to a record the site cannot see, or that no longer exists,\n * falls through to the address rather than filing under nobody.\n *\n * ## Where it lives\n *\n * In the admin library rather than the Bookings plugin, because the row is\n * a CRM record: its scope, its ceiling and its collections are the CRM's,\n * and a plugin that stamped its own would be a second creator to keep in\n * step with `crmScopeTokens`. The Bookings plugin passes what it already\n * holds and reads the outcome; the CRM plugin is never imported.\n *\n * Firestore is a parameter, as in `crm-records.ts`: the callers hold one,\n * and a module that reached for the Admin app itself would drag it into\n * every spec that only wants the arithmetic.\n *\n * ## Failure posture\n *\n * **Never throws.** A booking that could not be filed on the CRM is still\n * a booking — the guest has the slot and the confirmation — so a failure\n * here is logged and reported in the outcome, never raised into the\n * request that took the booking or the webhook that confirmed it.\n */\n\n/** The CRM plugin's id, as `enabledPlugins` and `disabledPlugins` name it. */\nconst CRM_PLUGIN_ID = 'crm'\n\nexport interface CrmBookingRecordInput {\n hostId: string\n /**\n * The owning org, as `getOrgForHost` returns it — the plugin set, the\n * consent groups and the scope default are all read off it.\n */\n org: Record<string, unknown> | null | undefined\n orgId: string\n /** The booking as its row was written. */\n booking: {\n id: string\n serviceId?: string\n serviceName?: string\n email?: string\n startsAtMs: number\n endsAtMs: number\n /** The `crm` query value the link carried, kept on the row. */\n crmRef?: string | null\n }\n /** The service as stored: its two CRM switches and its timezone. */\n service:\n | {\n name?: string\n timezone?: string\n crmFollowUpTask?: boolean\n crmMeetingActivity?: boolean\n }\n | null\n | undefined\n /**\n * The host document's plugin fields, when the caller already holds them;\n * read here otherwise. The deny-list is per site, and a site that has\n * switched the CRM off must file nothing.\n */\n host?: { disabledPlugins?: string[]; enabledPlugins?: string[] } | null\n}\n\nexport type CrmBookingRecordOutcome =\n | {\n filed: false\n reason:\n /** The CRM plugin does not run on this site. */\n | 'crm-off'\n /**\n * The org's plan does not carry the CRM suite, whose activities and\n * tasks these are (AGL-2787).\n */\n | 'not-entitled'\n /** Neither the reference nor the address named a record. */\n | 'no-record'\n /** A meeting for this booking is already on the timeline. */\n | 'already-filed'\n /** The service asks for neither a meeting nor a follow-up. */\n | 'nothing-to-file'\n /** A read or a write threw; logged, and nothing is promised. */\n | 'failed'\n }\n | {\n filed: true\n /** How the record was found. */\n matchedBy: 'crmRef' | 'email'\n link: CrmActivityLink\n /** The meeting's id, or `null` when the service files no meeting. */\n activityId: string | null\n /** The follow-up's id, or `null` when the service asks for none. */\n taskId: string | null\n }\n\ninterface ResolvedRecord {\n link: CrmActivityLink\n matchedBy: 'crmRef' | 'email'\n /** Whoever holds the relationship, for the follow-up's assignee. */\n ownerUid?: string\n}\n\n/**\n * The record a booking belongs to — the reference first, the address second.\n */\nasync function resolveBookingRecord(\n firestore: FirebaseFirestore.Firestore,\n input: CrmBookingRecordInput,\n groupId: string,\n): Promise<ResolvedRecord | null> {\n const { hostId, orgId } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contactsRef = orgRef.collection('contacts')\n const ref = parseCrmBookingRef(input.booking.crmRef)\n if (ref?.kind === 'contact') {\n const contact = await contactsRef.doc(ref.id).get()\n if (contact.exists && visibleToHost(contact.get('visibleTo'), hostId)) {\n const facet = readContactFacet(contact.data() ?? {}, groupId)\n return {\n link: {\n contactId: contact.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n },\n matchedBy: 'crmRef',\n ...(facet.ownerUid ? { ownerUid: facet.ownerUid } : {}),\n }\n }\n }\n if (ref?.kind === 'deal') {\n const deal = await orgRef.collection(CRM_COLLECTIONS.deals).doc(ref.id).get()\n if (deal.exists && visibleToHost(deal.get('visibleTo'), hostId)) {\n const contactId = String(deal.get('contactId') ?? '')\n const companyId = String(deal.get('companyId') ?? '')\n const ownerUid = String(deal.get('ownerUid') ?? '')\n return {\n link: {\n dealId: deal.id,\n ...(contactId ? { contactId } : {}),\n ...(companyId ? { companyId } : {}),\n },\n matchedBy: 'crmRef',\n ...(ownerUid ? { ownerUid } : {}),\n }\n }\n }\n if (ref?.kind === 'lead') {\n /*\n * A lead carries its own `visibleTo` now (AGL-3275) — it used to be\n * host-scoped by path, and the comment here said so. The seam reads the\n * org row, and the site has to be allowed to see it before a booking is\n * filed against it, or one brand's booking would name another's lead.\n */\n const lead = await readLeadForHost(hostId, ref.id)\n if (lead && visibleToHost(lead.get('visibleTo'), hostId)) {\n const ownerUid = String(lead.get('ownerUid') ?? '')\n return {\n link: { leadId: lead.id },\n matchedBy: 'crmRef',\n ...(ownerUid ? { ownerUid } : {}),\n }\n }\n }\n const hit = await findContactByEmail(contactsRef, input.booking.email, { hostId })\n if (!hit) return null\n const facet = readContactFacet(hit.data() ?? {}, groupId)\n return {\n link: {\n contactId: hit.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n },\n matchedBy: 'email',\n ...(facet.ownerUid ? { ownerUid: facet.ownerUid } : {}),\n }\n}\n\n/**\n * Files what a confirmed booking owes the CRM: the meeting, and the\n * follow-up when the service asks for one. See the module comment.\n */\nexport async function recordCrmBooking(\n firestore: FirebaseFirestore.Firestore,\n input: CrmBookingRecordInput,\n): Promise<CrmBookingRecordOutcome> {\n const { hostId, orgId, booking, service } = input\n try {\n if (!hostId || !orgId || !booking?.id) return { filed: false, reason: 'failed' }\n const wantsMeeting = service?.crmMeetingActivity !== false\n const wantsTask = service?.crmFollowUpTask === true\n if (!wantsMeeting && !wantsTask) return { filed: false, reason: 'nothing-to-file' }\n\n const host =\n input.host === undefined\n ? ((await firestore.collection('hosts').doc(hostId).get()).data() as\n | { disabledPlugins?: string[]; enabledPlugins?: string[] }\n | undefined) ?? null\n : input.host\n if (!isHostPluginEnabled(input.org ?? null, host, CRM_PLUGIN_ID)) {\n return { filed: false, reason: 'crm-off' }\n }\n if (!checkEntitlement(input.org as Parameters<typeof checkEntitlement>[0], 'crm')) {\n return { filed: false, reason: 'not-entitled' }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n const activities = orgRef.collection(CRM_COLLECTIONS.activities)\n // One meeting per booking, whatever path re-enters: the paid webhook is\n // redelivered for days after any 500, and its own guard is the status,\n // which this row is not part of.\n const already = await activities.where('bookingId', '==', booking.id).limit(1).get()\n if (!already.empty) return { filed: false, reason: 'already-filed' }\n\n const group = consentGroupForHost(input.org ?? null, hostId)\n const record = await resolveBookingRecord(firestore, input, group.groupId)\n if (!record) return { filed: false, reason: 'no-record' }\n\n const visibleTo = crmScopeTokens(input.org ?? null, group)\n const serviceName = String(service?.name ?? booking.serviceName ?? '').trim()\n const timezone = service?.timezone || undefined\n\n let activityId: string | null = null\n if (wantsMeeting) {\n // The per-record ceiling (AGL-2611): a full log refuses the meeting\n // but not the follow-up — a task is owed either way.\n const logged = await countCrmActivitiesForRecord(orgRef, record.link)\n if (crmActivityLogHasRoom(logged)) {\n const activity: CrmActivity = {\n kind: 'meeting',\n body: crmBookingMeetingBody({\n serviceName,\n startsAtMs: booking.startsAtMs,\n timezone,\n }),\n // When it HAPPENS, which is the slot — not when it was booked.\n atMs: booking.startsAtMs,\n byUid: '',\n ...record.link,\n bookingId: booking.id,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const written = await activities.add(activity)\n activityId = written.id\n }\n }\n\n let taskId: string | null = null\n if (wantsTask) {\n const task: CrmTask = {\n title: crmBookingFollowUpTitle(serviceName),\n kind: 'todo',\n priority: 'normal',\n status: 'open',\n dueAtMs: crmBookingFollowUpDueMs(booking.endsAtMs, timezone),\n ...(record.ownerUid ? { assigneeUid: record.ownerUid } : {}),\n createdByUid: '',\n ...record.link,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n const written = await orgRef\n .collection(CRM_COLLECTIONS.tasks)\n .add({ ...task, ...crmTaskListFields(task) })\n taskId = written.id\n }\n\n return { filed: true, matchedBy: record.matchedBy, link: record.link, activityId, taskId }\n } catch (error) {\n console.error('[crm] booking could not be filed', hostId, booking?.id, error)\n return { filed: false, reason: 'failed' }\n }\n}\n"],"names":["checkEntitlement","consentGroupForHost","CRM_COLLECTIONS","crmTaskListFields","crmActivityLogHasRoom","crmBookingFollowUpDueMs","crmBookingFollowUpTitle","crmBookingMeetingBody","crmScopeTokens","isHostPluginEnabled","parseCrmBookingRef","readContactFacet","visibleToHost","FieldValue","findContactByEmail","readLeadForHost","countCrmActivitiesForRecord","CRM_PLUGIN_ID","resolveBookingRecord","firestore","input","groupId","hit","hostId","orgId","orgRef","collection","doc","contactsRef","ref","booking","crmRef","kind","contact","id","get","exists","facet","data","link","contactId","companyId","matchedBy","ownerUid","deal","deals","String","dealId","lead","leadId","email","recordCrmBooking","service","filed","reason","wantsMeeting","crmMeetingActivity","wantsTask","crmFollowUpTask","host","undefined","org","activities","already","where","limit","empty","group","record","visibleTo","serviceName","name","trim","timezone","activityId","logged","activity","body","startsAtMs","atMs","byUid","bookingId","createdAt","serverTimestamp","updatedAt","written","add","taskId","task","title","priority","status","dueAtMs","endsAtMs","assigneeUid","createdByUid","tasks","error","console"],"mappings":"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"}
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { AglynOrgBilling } from '@aglyn/aglyn/foundation';
|
|
18
18
|
import { type CrmActivityLink, type CrmEmailQuotaResult, type CrmRecordsQuotaResult } from '@aglyn/aglyn/server';
|
|
19
|
+
import { type CrmListCollection } from '@aglyn/aglyn/app-utils/crm';
|
|
19
20
|
import { type EmailRampVerdict } from '@aglyn/shared-util-email';
|
|
20
21
|
/**
|
|
21
22
|
* The CRM RECORDS band's measurement (AGL-2611): the three collections it
|
|
@@ -142,3 +143,17 @@ export declare function reserveCrmEmailSend(firestore: FirebaseFirestore.Firesto
|
|
|
142
143
|
* org does not get back until midnight — the safe direction.
|
|
143
144
|
*/
|
|
144
145
|
export declare function releaseCrmEmailSend(firestore: FirebaseFirestore.Firestore, reservation: CrmEmailSendReservation | null | undefined): Promise<void>;
|
|
146
|
+
export interface CrmListFieldsRestamp {
|
|
147
|
+
/** Records whose list fields were written. */
|
|
148
|
+
restamped: number;
|
|
149
|
+
/** Records already current. */
|
|
150
|
+
current: number;
|
|
151
|
+
/** Records that no longer exist. */
|
|
152
|
+
missing: number;
|
|
153
|
+
}
|
|
154
|
+
/** Restamp one record's list fields from the record as stored. */
|
|
155
|
+
export declare function restampCrmListFieldsAt(ref: FirebaseFirestore.DocumentReference, collection: CrmListCollection): Promise<'restamped' | 'current' | 'missing'>;
|
|
156
|
+
/** Restamp several records of one collection, a few at a time. */
|
|
157
|
+
export declare function restampCrmListFieldsOf(refs: readonly FirebaseFirestore.DocumentReference[], collection: CrmListCollection): Promise<CrmListFieldsRestamp>;
|
|
158
|
+
/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */
|
|
159
|
+
export declare function restampCrmListFields(firestore: FirebaseFirestore.Firestore, orgId: string, collection: CrmListCollection, ids: readonly string[]): Promise<CrmListFieldsRestamp>;
|
|
@@ -15,6 +15,11 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
17
|
import { checkCrmEmailQuota, checkCrmRecordsQuota, CRM_COLLECTIONS, CRM_EMAIL_USAGE_COLLECTION, crmActivityCeilingLink, crmEmailUsageDayKey } from "@aglyn/aglyn/server";
|
|
18
|
+
/*
|
|
19
|
+
* By module path, not the barrel: the capture door's specs substitute a
|
|
20
|
+
* fixture barrel (see `upsert-contact.ts`), and the restamp is reached from
|
|
21
|
+
* the door.
|
|
22
|
+
*/ import { crmListFieldsPatch } from "@aglyn/aglyn/app-utils/crm";
|
|
18
23
|
import { rampedDailyAllowance } from "@aglyn/shared-util-email";
|
|
19
24
|
import { FieldValue } from "firebase-admin/firestore";
|
|
20
25
|
/**
|
|
@@ -214,5 +219,52 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
214
219
|
console.error('crm email reservation release failed', error);
|
|
215
220
|
}
|
|
216
221
|
}
|
|
222
|
+
/** Restamp one record's list fields from the record as stored. */ export async function restampCrmListFieldsAt(ref, collection) {
|
|
223
|
+
try {
|
|
224
|
+
return await ref.firestore.runTransaction(async (tx)=>{
|
|
225
|
+
var _snapshot_data;
|
|
226
|
+
const snapshot = await tx.get(ref);
|
|
227
|
+
if (!snapshot.exists) return 'missing';
|
|
228
|
+
const patch = crmListFieldsPatch(collection, (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {});
|
|
229
|
+
if (!Object.keys(patch).length) return 'current';
|
|
230
|
+
tx.update(ref, patch);
|
|
231
|
+
return 'restamped';
|
|
232
|
+
});
|
|
233
|
+
} catch (error) {
|
|
234
|
+
console.error(`[crm] list fields restamp failed for ${ref.path}`, error);
|
|
235
|
+
return 'current';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/** Restamp several records of one collection, a few at a time. */ export async function restampCrmListFieldsOf(refs, collection) {
|
|
239
|
+
const result = {
|
|
240
|
+
restamped: 0,
|
|
241
|
+
current: 0,
|
|
242
|
+
missing: 0
|
|
243
|
+
};
|
|
244
|
+
const queue = [
|
|
245
|
+
...new Map(refs.map((ref)=>[
|
|
246
|
+
ref.path,
|
|
247
|
+
ref
|
|
248
|
+
])).values()
|
|
249
|
+
];
|
|
250
|
+
// Each is its own small transaction on its own record.
|
|
251
|
+
const worker = async ()=>{
|
|
252
|
+
for(let ref = queue.shift(); ref !== undefined; ref = queue.shift()){
|
|
253
|
+
const outcome = await restampCrmListFieldsAt(ref, collection);
|
|
254
|
+
result[outcome] += 1;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
await Promise.all(Array.from({
|
|
258
|
+
length: Math.min(RESTAMP_CONCURRENCY, queue.length)
|
|
259
|
+
}, worker));
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */ export async function restampCrmListFields(firestore, orgId, collection, ids) {
|
|
263
|
+
const records = firestore.collection('orgs').doc(orgId).collection(collection);
|
|
264
|
+
return restampCrmListFieldsOf([
|
|
265
|
+
...new Set(ids.filter(Boolean))
|
|
266
|
+
].map((id)=>records.doc(id)), collection);
|
|
267
|
+
}
|
|
268
|
+
/** Records restamped at once by {@link restampCrmListFields}. */ const RESTAMP_CONCURRENCY = 8;
|
|
217
269
|
|
|
218
270
|
//# sourceMappingURL=crm-records.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-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\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","rampedDailyAllowance","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SACEA,kBAAkB,EAClBC,oBAAoB,EACpBC,eAAe,EACfC,0BAA0B,EAE1BC,sBAAsB,EAEtBC,mBAAmB,QAEd,sBAAqB;AAC5B,SAEEC,oBAAoB,QACf,2BAA0B;AACjC,SAASC,UAAU,QAAQ,2BAA0B;AAqBrD;;;;;;;;;CASC,GACD,OAAO,eAAeC,gBACpBC,MAA2C,EAC3CC,WAAkDD,OAAOE,UAAU,CAAC,WAAW;IAE/E,MAAM,CAACC,cAAcC,eAAeC,UAAU,GAAG,MAAMC,QAAQC,GAAG,CAAC;QACjEN,SAASO,KAAK,GAAGC,GAAG;QACpBT,OAAOE,UAAU,CAACT,gBAAgBiB,SAAS,EAAEF,KAAK,GAAGC,GAAG;QACxDT,OAAOE,UAAU,CAACT,gBAAgBkB,KAAK,EAAEH,KAAK,GAAGC,GAAG;KACrD;IACD,MAAMG,gBAAgBC,eAAeV;IACrC,MAAMW,iBAAiBD,eAAeT;IACtC,MAAMW,aAAaF,eAAeR;IAClC,OAAO;QACLO;QACAE;QACAC;QACAC,iBAAiBJ,gBAAgBE,iBAAiBC;IACpD;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeE,sBACpBC,GAAgD,EAChDlB,MAA2C,EAC3CC,QAAgD;IAEhD,MAAMkB,SAAS,MAAMpB,gBAAgBC,QAAQC;IAC7C,OAAO,aAAKT,qBAAqB0B,KAAKC,OAAOH,eAAe,GAAMG;AACpE;AAEA,oFAAoF,GACpF,SAASN,eAAeO,QAAyC;;QAC1CA;IAArB,MAAMC,QAAQC,gBAAOF,iBAAAA,SAASG,IAAI,uBAAbH,eAAiBZ,KAAK,mBAAI;IAC/C,OAAOc,OAAOE,QAAQ,CAACH,UAAUA,QAAQ,IAAII,KAAKC,KAAK,CAACL,SAAS;AACnE;AAEA;;;;CAIC,GACD,OAAO,eAAeM,4BACpB3B,MAA2C,EAC3C4B,IAAqB;IAErB,MAAMC,OAAOlC,uBAAuBiC;IACpC,IAAI,CAACC,MAAM,OAAO;IAClB,MAAMT,WAAW,MAAMpB,OACpBE,UAAU,CAACT,gBAAgBqC,UAAU,EACrCC,KAAK,CAACF,KAAKG,KAAK,EAAE,MAAMH,KAAKI,EAAE,EAC/BzB,KAAK,GACLC,GAAG;IACN,OAAOI,eAAeO;AACxB;AAEA;;;;CAIC,GACD,SAASc,uBACPC,SAAsC,EACtCC,KAAa,EACbC,GAAW;IAEX,OAAOF,UACJjC,UAAU,CAAC,QACXoC,GAAG,CAACF,OACJlC,UAAU,CAACR,4BACX4C,GAAG,CAACD;AACT;AAEA,8EAA8E,GAC9E,OAAO,SAASE,iBACdJ,SAAsC,EACtCC,KAAa,EACbI,MAAY,IAAIC,MAAM;IAEtB,OAAOP,uBAAuBC,WAAWC,OAAOxC,oBAAoB4C;AACtE;AAEA;;;;;;;CAOC,GACD,SAASE,cAActB,QAAyC;QACzCA;IAArB,MAAMC,QAAQC,QAAOF,gBAAAA,SAASX,GAAG,CAAC,oBAAbW,gBAAyB;IAC9C,OAAOE,OAAOE,QAAQ,CAACH,UAAUA,QAAQ,IAAII,KAAKC,KAAK,CAACL,SAAS;AACnE;AAEA;;;;CAIC,GACD,OAAO,eAAesB,mBACpBR,SAAsC,EACtCC,KAAa,EACbI,MAAY,IAAIC,MAAM;IAEtB,OAAOC,cAAc,MAAMH,iBAAiBJ,WAAWC,OAAOI,KAAK/B,GAAG;AACxE;AAgBA;;;;;;;;;;;CAWC,GACD,SAASmC,oBACPC,IAAyB,EACzBC,IAAyC;IAEzC,MAAMC,WAAWlD,qBAAqBgD,KAAKE,QAAQ,EAAED;IACrD,IAAIC,YAAYF,KAAKE,QAAQ,EAAE,OAAOF;IACtC,OAAO,aACFA;QACHE;QACAC,SAASH,KAAKI,IAAI,GAAGF;QACrBG,WAAWzB,KAAK0B,GAAG,CAAC,GAAGJ,WAAWF,KAAKI,IAAI;;AAE/C;AAEA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0CC,GACD,OAAO,eAAeG,oBACpBjB,SAAsC,EACtCC,KAAa,EACblB,GAAgD,EAChDsB,MAAY,IAAIC,MAAM,EACtBK,IAA8B;IAE9B,MAAMT,MAAMzC,oBAAoB4C;IAChC,MAAMa,MAAMnB,uBAAuBC,WAAWC,OAAOC;IACrD,OAAOF,UAAUmB,cAAc,CAAC,OAAOC;QACrC,MAAMC,QAAQZ,oBACZrD,mBAAmB2B,KAAKwB,cAAc,MAAMa,GAAG9C,GAAG,CAAC4C,OAAOb,MAC1DM;QAEF,IAAI,CAACU,MAAMR,OAAO,EAAE,OAAO;YAAES,IAAI;YAAOD;QAAM;QAC9CD,GAAGG,GAAG,CACJL,KACA;YAAE7C,OAAOgD,MAAMP,IAAI,GAAG;YAAGZ;YAAKsB,WAAW7D,WAAW8D,eAAe;QAAG,GACtE;YAAEC,OAAO;QAAK;QAEhB,OAAO;YAAEJ,IAAI;YAAMK,aAAa;gBAAE1B;gBAAOC;YAAI;YAAGmB;QAAM;IACxD;AACF;AAEA;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeO,oBACpB5B,SAAsC,EACtC2B,WAAuD;IAEvD,IAAI,EAACA,+BAAAA,YAAa1B,KAAK,KAAI,CAAC0B,YAAYzB,GAAG,EAAE;IAC7C,MAAMgB,MAAMnB,uBAAuBC,WAAW2B,YAAY1B,KAAK,EAAE0B,YAAYzB,GAAG;IAChF,IAAI;QACF,MAAMF,UAAUmB,cAAc,CAAC,OAAOC;YACpC,MAAMN,OAAOP,cAAc,MAAMa,GAAG9C,GAAG,CAAC4C;YACxCE,GAAGG,GAAG,CACJL,KACA;gBACE7C,OAAOiB,KAAK0B,GAAG,CAAC,GAAGF,OAAO;gBAC1BZ,KAAKyB,YAAYzB,GAAG;gBACpBsB,WAAW7D,WAAW8D,eAAe;YACvC,GACA;gBAAEC,OAAO;YAAK;QAElB;IACF,EAAE,OAAOG,OAAO;QACdC,QAAQD,KAAK,CAAC,wCAAwCA;IACxD;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/crm-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\nimport type { AglynOrgBilling } from '@aglyn/aglyn/foundation'\nimport {\n checkCrmEmailQuota,\n checkCrmRecordsQuota,\n CRM_COLLECTIONS,\n CRM_EMAIL_USAGE_COLLECTION,\n type CrmActivityLink,\n crmActivityCeilingLink,\n type CrmEmailQuotaResult,\n crmEmailUsageDayKey,\n type CrmRecordsQuotaResult,\n} from '@aglyn/aglyn/server'\n/*\n * By module path, not the barrel: the capture door's specs substitute a\n * fixture barrel (see `upsert-contact.ts`), and the restamp is reached from\n * the door.\n */\nimport { type CrmListCollection, crmListFieldsPatch } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type EmailRampVerdict,\n rampedDailyAllowance,\n} from '@aglyn/shared-util-email'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The CRM RECORDS band's measurement (AGL-2611): the three collections it\n * counts, and their sum.\n *\n * One shape for every server that asks, because the band is enforced at six\n * doors — contact capture, the console's company and deal drawers (through\n * the client twin of this read), the CSV import, the lead conversion and the\n * two REST creates — and the monthly rollup bills from the same three\n * aggregates. A door that counted contacts alone would admit a company on a\n * band the rollup had already found full.\n */\nexport interface CrmRecordsCount {\n contactsCount: number\n companiesCount: number\n dealsCount: number\n /** The band's own figure: the three above, summed. */\n crmRecordsCount: number\n}\n\n/**\n * Count the records band for one org: three `count()` aggregates, in\n * parallel, no document reads.\n *\n * `contacts` may be handed in by a caller that already holds the collection\n * reference — `upsertHostContact` resolves it through `orgDataCollection`\n * before it decides anything — so the read is the same one whichever door\n * it came through, and the org reference is needed only for the two\n * collections the hub added.\n */\nexport async function countCrmRecords(\n orgRef: FirebaseFirestore.DocumentReference,\n contacts: FirebaseFirestore.CollectionReference = orgRef.collection('contacts'),\n): Promise<CrmRecordsCount> {\n const [contactsSnap, companiesSnap, dealsSnap] = await Promise.all([\n contacts.count().get(),\n orgRef.collection(CRM_COLLECTIONS.companies).count().get(),\n orgRef.collection(CRM_COLLECTIONS.deals).count().get(),\n ])\n const contactsCount = aggregateCount(contactsSnap)\n const companiesCount = aggregateCount(companiesSnap)\n const dealsCount = aggregateCount(dealsSnap)\n return {\n contactsCount,\n companiesCount,\n dealsCount,\n crmRecordsCount: contactsCount + companiesCount + dealsCount,\n }\n}\n\n/**\n * The band's verdict for one org, measured now — the read every create\n * door takes before it writes, so the six doors cannot disagree about\n * whether there is room. `checkCrmRecordsQuota` answers from the org doc\n * alone; this pairs it with the measurement.\n */\nexport async function crmRecordsQuotaForOrg(\n org: Partial<AglynOrgBilling> | null | undefined,\n orgRef: FirebaseFirestore.DocumentReference,\n contacts?: FirebaseFirestore.CollectionReference,\n): Promise<CrmRecordsQuotaResult & CrmRecordsCount> {\n const counts = await countCrmRecords(orgRef, contacts)\n return { ...checkCrmRecordsQuota(org, counts.crmRecordsCount), ...counts }\n}\n\n/** `count()` answers through `.data().count`; a missing figure is zero, never NaN. */\nfunction aggregateCount(snapshot: { data(): { count?: unknown } }): number {\n const value = Number(snapshot.data()?.count ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many activities one record already carries — one aggregate on a\n * single-field equality, which Firestore indexes on its own, so this needs\n * no composite index and cannot go FAILED_PRECONDITION in production.\n */\nexport async function countCrmActivitiesForRecord(\n orgRef: FirebaseFirestore.DocumentReference,\n link: CrmActivityLink,\n): Promise<number> {\n const lead = crmActivityCeilingLink(link)\n if (!lead) return 0\n const snapshot = await orgRef\n .collection(CRM_COLLECTIONS.activities)\n .where(lead.field, '==', lead.id)\n .count()\n .get()\n return aggregateCount(snapshot)\n}\n\n/**\n * `orgs/{orgId}/crmEmailUsage/{day}` — one UTC day's one-to-one email\n * counter, the document `checkCrmEmailQuota` is enforced against and the\n * billing page reads.\n */\nfunction crmEmailUsageRefForDay(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n day: string,\n): FirebaseFirestore.DocumentReference {\n return firestore\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_EMAIL_USAGE_COLLECTION)\n .doc(day)\n}\n\n/** Today's counter document — {@link crmEmailUsageRefForDay} keyed by `now`. */\nexport function crmEmailUsageRef(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): FirebaseFirestore.DocumentReference {\n return crmEmailUsageRefForDay(firestore, orgId, crmEmailUsageDayKey(now))\n}\n\n/**\n * The counter a day-document holds, clamped.\n *\n * A malformed or absent counter reads as ZERO SENT, which is the permissive\n * direction — deliberately, and the same clamp `checkApiRequestQuota`'s\n * reader applies: a counter that cannot be read must not refuse a rep who\n * has sent nothing, and the cap is a pace rather than a cost.\n */\nfunction crmEmailCount(snapshot: { get(field: string): unknown }): number {\n const value = Number(snapshot.get('count') ?? 0)\n return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0\n}\n\n/**\n * How many one-to-one emails the org has sent today, for a meter — never\n * for the cap, which {@link reserveCrmEmailSend} judges inside the\n * transaction that also takes the slot.\n */\nexport async function crmEmailsSentToday(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n now: Date = new Date(),\n): Promise<number> {\n return crmEmailCount(await crmEmailUsageRef(firestore, orgId, now).get())\n}\n\n/** One slot on one day's counter, held from the reservation to the send. */\nexport interface CrmEmailSendReservation {\n orgId: string\n /**\n * The UTC day the slot was taken on, so a release after midnight gives\n * the slot back to the day that lent it rather than to the new day.\n */\n day: string\n}\n\nexport type ReserveCrmEmailSendResult =\n | { ok: true; reservation: CrmEmailSendReservation; quota: CrmEmailQuotaResult }\n | { ok: false; quota: CrmEmailQuotaResult }\n\n/**\n * The plan's day, narrowed to the share of it a young workspace has earned.\n *\n * The new-sender ramp reaches the one-to-one path here and only here, so the\n * ceiling the transaction below judges against is one value with one origin.\n * The alternative — a full-allowance reservation with a ramp consulted beside\n * it — is two ceilings for one decision, and under two concurrent senders it\n * admits against whichever of them was read first.\n *\n * `included` is the only field the ramp moves; `resetsAt` is the day's, which\n * a share does not change, and `used` is what the transaction read.\n */\nfunction rampedCrmEmailQuota(\n plan: CrmEmailQuotaResult,\n ramp: EmailRampVerdict | null | undefined,\n): CrmEmailQuotaResult {\n const included = rampedDailyAllowance(plan.included, ramp)\n if (included >= plan.included) return plan\n return {\n ...plan,\n included,\n allowed: plan.used < included,\n remaining: Math.max(0, included - plan.used),\n }\n}\n\n/**\n * Claims ONE one-to-one send against today's cap, ATOMICALLY (AGL-2645).\n *\n * The counter used to be read before the send and incremented after\n * delivery, so two reps sending in the same window both passed the same\n * reading and both sent — `CRM_EMAIL_SENDS_PER_MINUTE` past the cap per\n * sender, in exactly the conditions the cap exists for. **A read-then-write\n * cap is not a cap**, the lesson `reserveCampaignEmailSends` learned for\n * campaigns; this is the same shape on the day counter.\n *\n * The transaction reads the counter, judges it with `checkCrmEmailQuota` —\n * the real plan table, `UNLIMITED` on Enterprise, zero on a tier with no\n * suite — and writes an ABSOLUTE value derived from that read, deliberately\n * not `FieldValue.increment`. Firestore aborts and re-runs the callback\n * when a document the transaction read has moved, so a second sender that\n * starts inside the first one's window re-reads the raised figure and is\n * refused. An increment would be atomic on the number and useless for the\n * decision, because the decision is made from a value the write never\n * proves it still held.\n *\n * A refused reservation writes NOTHING. A taken one is the count the cap\n * enforces and the billing page shows, from the moment it is taken: a send\n * the provider then refuses hands the slot back through\n * {@link releaseCrmEmailSend}, and a process that dies between the two\n * leaves the org one send short for the rest of the UTC day — conservative\n * in the direction a cap cares about, and healed at midnight, because each\n * day is its own document.\n *\n * ## The new-sender ramp rides the same read\n *\n * `ramp` narrows the plan's day to the share of it a young workspace has\n * earned, INSIDE the transaction and before the counter is judged — see\n * {@link rampedCrmEmailQuota}. Applying it here rather than in the caller is\n * the whole of what makes it safe: the ceiling and the count it is compared\n * against come out of one read, so the retry Firestore forces on a losing\n * transaction re-derives both. A ramp checked outside would be a second\n * ceiling, decided from a snapshot the write never proves it still held —\n * the defect this reservation was built to remove, one field over.\n *\n * The ramp is optional and an absent one is not a throttle: a caller that\n * did not resolve one gets the plan's day, which is what every surface but\n * the send path wants.\n */\nexport async function reserveCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n org: Partial<AglynOrgBilling> | null | undefined,\n now: Date = new Date(),\n ramp?: EmailRampVerdict | null,\n): Promise<ReserveCrmEmailSendResult> {\n const day = crmEmailUsageDayKey(now)\n const ref = crmEmailUsageRefForDay(firestore, orgId, day)\n return firestore.runTransaction(async (tx) => {\n const quota = rampedCrmEmailQuota(\n checkCrmEmailQuota(org, crmEmailCount(await tx.get(ref)), now),\n ramp,\n )\n if (!quota.allowed) return { ok: false, quota }\n tx.set(\n ref,\n { count: quota.used + 1, day, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n return { ok: true, reservation: { orgId, day }, quota }\n })\n}\n\n/**\n * Gives back a slot the provider did not use.\n *\n * Also a transaction, and also an absolute write from its own read: a\n * decrement computed from a stale figure would undo a slot another rep took\n * in the meantime, which is the same defect one direction over. Never\n * drives the counter below zero.\n *\n * **Never throws.** This runs on the failure path of a send that was\n * already refused, and the answer the rep gets is the provider's refusal; a\n * bookkeeping failure on top of it is logged, and its cost is one slot the\n * org does not get back until midnight — the safe direction.\n */\nexport async function releaseCrmEmailSend(\n firestore: FirebaseFirestore.Firestore,\n reservation: CrmEmailSendReservation | null | undefined,\n): Promise<void> {\n if (!reservation?.orgId || !reservation.day) return\n const ref = crmEmailUsageRefForDay(firestore, reservation.orgId, reservation.day)\n try {\n await firestore.runTransaction(async (tx) => {\n const used = crmEmailCount(await tx.get(ref))\n tx.set(\n ref,\n {\n count: Math.max(0, used - 1),\n day: reservation.day,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.error('crm email reservation release failed', error)\n }\n}\n\n/*==========================================\n * THE LIST FIELDS, RESTAMPED FROM WHAT WAS STORED (AGL-3321).\n *\n * A CRM list queries fields derived from the record — its search tokens, a\n * lead's status and verdict keys, a contact's facet keys (`crmListFields`\n * in `@aglyn/aglyn`). A writer that holds the whole document spreads them;\n * a writer that PATCHED an input — a dotted facet field, an `arrayUnion` of\n * tags, a merged capture — cannot know the result without reading it, so\n * it restamps here after its own write: the record is read back in a\n * transaction and only the list fields it now carries wrongly are written.\n *\n * Never throws. A restamp is a consequence of a write that already landed,\n * and failing the caller would report the write as failed; a record left\n * stale is what the one-time backfill (`backfill-crm-list-fields.mjs`)\n * brings level, and the next write to it restamps it again.\n *=========================================*/\n\nexport interface CrmListFieldsRestamp {\n /** Records whose list fields were written. */\n restamped: number\n /** Records already current. */\n current: number\n /** Records that no longer exist. */\n missing: number\n}\n\n/** Restamp one record's list fields from the record as stored. */\nexport async function restampCrmListFieldsAt(\n ref: FirebaseFirestore.DocumentReference,\n collection: CrmListCollection,\n): Promise<'restamped' | 'current' | 'missing'> {\n try {\n return await ref.firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(ref)\n if (!snapshot.exists) return 'missing' as const\n const patch = crmListFieldsPatch(collection, snapshot.data() ?? {})\n if (!Object.keys(patch).length) return 'current' as const\n tx.update(ref, patch)\n return 'restamped' as const\n })\n } catch (error) {\n console.error(`[crm] list fields restamp failed for ${ref.path}`, error)\n return 'current'\n }\n}\n\n/** Restamp several records of one collection, a few at a time. */\nexport async function restampCrmListFieldsOf(\n refs: readonly FirebaseFirestore.DocumentReference[],\n collection: CrmListCollection,\n): Promise<CrmListFieldsRestamp> {\n const result: CrmListFieldsRestamp = { restamped: 0, current: 0, missing: 0 }\n const queue = [...new Map(refs.map((ref) => [ref.path, ref])).values()]\n // Each is its own small transaction on its own record.\n const worker = async () => {\n for (let ref = queue.shift(); ref !== undefined; ref = queue.shift()) {\n const outcome = await restampCrmListFieldsAt(ref, collection)\n result[outcome] += 1\n }\n }\n await Promise.all(Array.from({ length: Math.min(RESTAMP_CONCURRENCY, queue.length) }, worker))\n return result\n}\n\n/** Restamp several records of one collection under `orgs/{orgId}/`, by id. */\nexport async function restampCrmListFields(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n collection: CrmListCollection,\n ids: readonly string[],\n): Promise<CrmListFieldsRestamp> {\n const records = firestore.collection('orgs').doc(orgId).collection(collection)\n return restampCrmListFieldsOf(\n [...new Set(ids.filter(Boolean))].map((id) => records.doc(id)),\n collection,\n )\n}\n\n/** Records restamped at once by {@link restampCrmListFields}. */\nconst RESTAMP_CONCURRENCY = 8\n"],"names":["checkCrmEmailQuota","checkCrmRecordsQuota","CRM_COLLECTIONS","CRM_EMAIL_USAGE_COLLECTION","crmActivityCeilingLink","crmEmailUsageDayKey","crmListFieldsPatch","rampedDailyAllowance","FieldValue","countCrmRecords","orgRef","contacts","collection","contactsSnap","companiesSnap","dealsSnap","Promise","all","count","get","companies","deals","contactsCount","aggregateCount","companiesCount","dealsCount","crmRecordsCount","crmRecordsQuotaForOrg","org","counts","snapshot","value","Number","data","isFinite","Math","floor","countCrmActivitiesForRecord","link","lead","activities","where","field","id","crmEmailUsageRefForDay","firestore","orgId","day","doc","crmEmailUsageRef","now","Date","crmEmailCount","crmEmailsSentToday","rampedCrmEmailQuota","plan","ramp","included","allowed","used","remaining","max","reserveCrmEmailSend","ref","runTransaction","tx","quota","ok","set","updatedAt","serverTimestamp","merge","reservation","releaseCrmEmailSend","error","console","restampCrmListFieldsAt","exists","patch","Object","keys","length","update","path","restampCrmListFieldsOf","refs","result","restamped","current","missing","queue","Map","map","values","worker","shift","undefined","outcome","Array","from","min","RESTAMP_CONCURRENCY","restampCrmListFields","ids","records","Set","filter","Boolean"],"mappings":"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"}
|
|
@@ -40,6 +40,22 @@ export declare function resetCspAggregateStateForTests(): void;
|
|
|
40
40
|
* hand the id to whoever controls the URL.
|
|
41
41
|
*/
|
|
42
42
|
export declare function cspBlockedOrigin(blockedUri: string): string;
|
|
43
|
+
/** The written field the staff CSP table's search reads. */
|
|
44
|
+
export declare const CSP_SEARCH_TOKENS_PATH = "searchTokens";
|
|
45
|
+
/**
|
|
46
|
+
* The search tokens one counter carries: the word prefixes of its blocked
|
|
47
|
+
* origin, its directive and its app — each whole, and each run of letters
|
|
48
|
+
* and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,
|
|
49
|
+
* `script`, `elem` and `tenant` all find the counters they name. The
|
|
50
|
+
* lower-casing and the prefixes are `nameSearchTokens`, the normalizer the
|
|
51
|
+
* list query asks with. Not the sampled site and path: they change with every
|
|
52
|
+
* report, and a merge would leave tokens for a site the row no longer shows.
|
|
53
|
+
*/
|
|
54
|
+
export declare function cspSearchTokens(key: {
|
|
55
|
+
origin?: unknown;
|
|
56
|
+
directive?: unknown;
|
|
57
|
+
app?: unknown;
|
|
58
|
+
}): string[];
|
|
43
59
|
/** UTC day bucket, `YYYY-MM-DD`. */
|
|
44
60
|
export declare function cspAggregateDay(nowMs: number): string;
|
|
45
61
|
export interface RecordCspViolationsOptions {
|
|
@@ -83,7 +83,18 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
83
83
|
* for it, and Firestore denies what nothing matches, so the Admin SDK is the
|
|
84
84
|
* only path in. The staff read-back is `/api/admin/csp-reports` on the
|
|
85
85
|
* console.
|
|
86
|
+
*
|
|
87
|
+
* ## The search tokens
|
|
88
|
+
*
|
|
89
|
+
* The staff table's search reads `searchTokens` with `array-contains`
|
|
90
|
+
* (AGL-3321), so every write stamps it, through {@link cspSearchTokens}. It
|
|
91
|
+
* is derived from the counter's KEY — origin, directive, app — which is the
|
|
92
|
+
* document id and never changes, so the array is the same on every write to
|
|
93
|
+
* one document and a merge cannot leave it describing something else.
|
|
94
|
+
* `tools/scripts/backfill-csp-search-tokens.mjs` stamps the counters written
|
|
95
|
+
* before it, held to `csp-search-tokens.fixtures.json` beside it.
|
|
86
96
|
*/ import { FieldValue } from "firebase-admin/firestore";
|
|
97
|
+
import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
|
|
87
98
|
import { isDocumentId } from "./document-id.js";
|
|
88
99
|
/** Top-level collection of counter documents. Server-only; no rules match. */ export const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily';
|
|
89
100
|
/**
|
|
@@ -164,6 +175,26 @@ let budgetUsed = 0;
|
|
|
164
175
|
return sanitizePart(blockedUri, 40);
|
|
165
176
|
}
|
|
166
177
|
}
|
|
178
|
+
/** The written field the staff CSP table's search reads. */ export const CSP_SEARCH_TOKENS_PATH = 'searchTokens';
|
|
179
|
+
/**
|
|
180
|
+
* The search tokens one counter carries: the word prefixes of its blocked
|
|
181
|
+
* origin, its directive and its app — each whole, and each run of letters
|
|
182
|
+
* and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,
|
|
183
|
+
* `script`, `elem` and `tenant` all find the counters they name. The
|
|
184
|
+
* lower-casing and the prefixes are `nameSearchTokens`, the normalizer the
|
|
185
|
+
* list query asks with. Not the sampled site and path: they change with every
|
|
186
|
+
* report, and a merge would leave tokens for a site the row no longer shows.
|
|
187
|
+
*/ export function cspSearchTokens(key) {
|
|
188
|
+
const words = [
|
|
189
|
+
key.origin,
|
|
190
|
+
key.directive,
|
|
191
|
+
key.app
|
|
192
|
+
].map((value)=>typeof value === 'string' ? value.trim().toLowerCase() : '').filter(Boolean).flatMap((value)=>[
|
|
193
|
+
value,
|
|
194
|
+
...value.split(/[^a-z0-9]+/)
|
|
195
|
+
]).filter(Boolean);
|
|
196
|
+
return nameSearchTokens(words.join(' '));
|
|
197
|
+
}
|
|
167
198
|
/** UTC day bucket, `YYYY-MM-DD`. */ export function cspAggregateDay(nowMs) {
|
|
168
199
|
return new Date(nowMs).toISOString().slice(0, 10);
|
|
169
200
|
}
|
|
@@ -247,6 +278,11 @@ function normalizeDirective(directive) {
|
|
|
247
278
|
origin,
|
|
248
279
|
disposition: sanitizePart(violation.disposition || 'report', 16),
|
|
249
280
|
count: FieldValue.increment(n),
|
|
281
|
+
[CSP_SEARCH_TOKENS_PATH]: cspSearchTokens({
|
|
282
|
+
origin,
|
|
283
|
+
directive,
|
|
284
|
+
app: options.app
|
|
285
|
+
}),
|
|
250
286
|
lastSeenMs: now,
|
|
251
287
|
// Samples, not keys: enough to find the surface, no power to
|
|
252
288
|
// mint documents. Already clamped by the parser; clamped again
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/csp-aggregate.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 * Durable CSP-violation aggregates (AGL-1799).\n *\n * The collectors were delivering reports into a sink that forgets: the Vercel\n * runtime log retains ~60 minutes and there is no log drain, so AGL-1702's\n * \"a week of signed-in traffic\" and AGL-1726's \"a business week of visitor\n * traffic\" were gated on evidence that structurally could not exist. This\n * module is the durable half — the log lines stay exactly as they were (they\n * are still the right tool for the last hour), and every accepted violation\n * ALSO compounds into a Firestore counter that holds for the retention window.\n *\n * ## Shape: counters, never report bodies\n *\n * One document per (UTC day x app x directive x disposition x blocked origin),\n * with `FieldValue.increment` — NOT one document per report. AGL-523 made a\n * deliberate choice not to write report bodies to Firestore from an\n * unauthenticated route, and AGL-1799 argued the distinction explicitly: \"a\n * counter with a bounded key space is a different proposition from storing\n * report bodies\". The bound is what this module is careful about, because\n * every key ingredient except the day is attacker-influenced:\n *\n * - the DIRECTIVE is normalized into a closed set (a real browser can only\n * ever send a spec directive; anything else lands in `other` rather than\n * minting a document per invented directive);\n * - the ORIGIN is reduced to the host of `blocked-uri` (or its bare keyword\n * — `inline`, `eval`, `data`), sanitized to a fixed character set, and\n * capped: at most {@link MAX_DISTINCT_ORIGINS_PER_DAY} distinct origins per\n * (day x app x directive) may mint documents per instance per day. Beyond\n * that, new origins are dropped while known ones keep counting — a flood\n * of unique URLs (the AGL-1769 lesson: never let request content mint\n * unbounded documents) saturates the cap instead of the collection;\n * - WRITES are budgeted per instance per minute, so a POST flood cannot turn\n * an unauthenticated beacon into a Firestore write bill even against\n * already-minted documents.\n *\n * Every cap here is per instance, like the tenant collector's `keyStore`: the\n * fleet-wide worst case is (instances x cap), which is bounded and small,\n * and an instance recycle merely re-counts an origin against a fresh cap —\n * the merge write is idempotent about which document it lands in.\n *\n * ## Ids are attacker-derived, so they go through `isDocumentId`\n *\n * Key parts are sanitized to `[a-z0-9.:_-]` and joined with `|`, then the\n * whole id is checked with {@link isDocumentId} (the AGL-1771 predicate —\n * imported from its leaf, `./document-id`, which imports nothing). Belt and\n * braces: the sanitizer already removes `/` and the joiner makes a reserved\n * `__x__` form impossible, but the predicate is the contract and it is\n * cheaper to check than to argue.\n *\n * ## Retention is TTL, not a sweep\n *\n * `expiresAt` (a Timestamp — TTL refuses numbers) is set\n * {@link CSP_AGGREGATE_RETENTION_DAYS} days past the day bucket. The TTL\n * policy is a gcloud action plus a `fieldOverrides` entry in\n * `cloud/firebase-firestore.indexes.json` — see\n * docs/FIRESTORE_MANUAL_CONFIG.md (AGL-1793): the entry ships with this\n * module and the gcloud enable is owed at deploy time.\n *\n * ## Fail-soft, like everything on this path\n *\n * A collector must never turn an observed problem into a served one. Every\n * failure here — no credential, Firestore down, a hostile id that survives\n * sanitization — drops the aggregate (one `console.error` line, tagged) and\n * returns. The log lines the collectors already emit are unaffected.\n *\n * No client ever reads or writes this collection: there is no rules `match`\n * for it, and Firestore denies what nothing matches, so the Admin SDK is the\n * only path in. The staff read-back is `/api/admin/csp-reports` on the\n * console.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CspViolation } from '@aglyn/aglyn/app-utils/csp-report'\nimport { isDocumentId } from './document-id'\n\n/** Top-level collection of counter documents. Server-only; no rules match. */\nexport const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily'\n\n/**\n * Long enough that \"a week of traffic\" (AGL-1702 condition 1, AGL-1726\n * condition 3) is readable with room to be read late, short enough that the\n * collection stays a working set rather than an archive.\n */\nexport const CSP_AGGREGATE_RETENTION_DAYS = 60\n\n/**\n * Distinct blocked origins that may mint documents per (day x app x\n * directive), per instance. A legitimate policy violates against a handful of\n * hosts; thirty is generous headroom for a real regression while keeping a\n * flood of attacker-invented origins from minting unbounded documents.\n */\nexport const MAX_DISTINCT_ORIGINS_PER_DAY = 30\n\n/**\n * Aggregate writes allowed per instance per minute. Distinct-origin capping\n * bounds document COUNT; this bounds write OPS, which are billed even when\n * they land on existing documents. Sized far above what real browsers on one\n * instance produce and far below what makes a flood expensive.\n */\nconst WRITE_BUDGET_PER_MINUTE = 120\n\n/**\n * The directives a browser can actually report, per the CSP3 registry. An\n * unrecognized directive is a non-browser caller (or a future spec revision)\n * and shares one bucket rather than minting its own key space.\n */\nconst KNOWN_DIRECTIVES = new Set([\n 'base-uri',\n 'block-all-mixed-content',\n 'child-src',\n 'connect-src',\n 'default-src',\n 'font-src',\n 'form-action',\n 'frame-ancestors',\n 'frame-src',\n 'img-src',\n 'manifest-src',\n 'media-src',\n 'object-src',\n 'prefetch-src',\n 'script-src',\n 'script-src-attr',\n 'script-src-elem',\n 'style-src',\n 'style-src-attr',\n 'style-src-elem',\n 'upgrade-insecure-requests',\n 'worker-src',\n])\n\n/**\n * Per-instance mint tracker: (day|app|directive) to the origin|disposition\n * pairs that have been allowed to mint documents. Cleared wholesale at the\n * cap, like the tenant collector's `keyStore`: the failure mode of clearing\n * is a brief widening of the mint cap, strictly better than an unbounded map\n * fed by attacker-chosen keys.\n */\nconst mintTracker = new Map<string, Set<string>>()\nconst MAX_TRACKED_GROUPS = 512\n\n/** Per-instance write budget window. */\nlet budgetWindowStartMs = 0\nlet budgetUsed = 0\n\n/** Test seam: module-scope state would otherwise leak across cases. */\nexport function resetCspAggregateStateForTests(): void {\n mintTracker.clear()\n budgetWindowStartMs = 0\n budgetUsed = 0\n}\n\n/** One id/field component: lowercase, fixed charset, never empty. */\nfunction sanitizePart(value: string, max: number): string {\n const cleaned = value\n .toLowerCase()\n .replace(/[^a-z0-9.:_-]+/g, '-')\n .slice(0, max)\n return cleaned || 'unknown'\n}\n\n/**\n * `blocked-uri` reduced to the coarsest thing worth counting: the HOST for a\n * URL, the bare keyword (`inline`, `eval`, `data`, ...) otherwise. Paths are\n * deliberately dropped — twelve chunk URLs off one CDN are one origin-level\n * fact (the AGL-1779 shape), and a path would both explode the key space and\n * hand the id to whoever controls the URL.\n */\nexport function cspBlockedOrigin(blockedUri: string): string {\n if (!blockedUri) return 'none'\n try {\n return sanitizePart(new URL(blockedUri).host, 120)\n } catch {\n return sanitizePart(blockedUri, 40)\n }\n}\n\n/** UTC day bucket, `YYYY-MM-DD`. */\nexport function cspAggregateDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n\nfunction normalizeDirective(directive: string): string {\n return KNOWN_DIRECTIVES.has(directive) ? directive : 'other'\n}\n\nexport interface RecordCspViolationsOptions {\n /** Which collector this came through — part of the counter key. */\n app: 'console' | 'tenant'\n /**\n * The reporting site (tenant only), REQUEST-derived and already clamped by\n * the caller. Stored as a sample field, never in the id: distinct sites\n * must not multiply the key space.\n */\n site?: string\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/**\n * Compound `violations` into the daily counters. Never throws; returns how\n * many documents were written (0 on any failure, budget exhaustion included).\n */\nexport async function recordCspViolations(\n violations: readonly CspViolation[],\n options: RecordCspViolationsOptions,\n): Promise<number> {\n try {\n if (!violations.length) return 0\n const now = options.now ?? Date.now()\n const day = cspAggregateDay(now)\n const dayStartMs = Date.parse(`${day}T00:00:00.000Z`)\n\n // Group first: N occurrences of one key in a batch are ONE write carrying\n // increment(N), so a page that repeats itself costs one op, not N.\n const grouped = new Map<\n string,\n { n: number; directive: string; origin: string; violation: CspViolation }\n >()\n for (const violation of violations) {\n const directive = normalizeDirective(violation.effectiveDirective)\n const origin = cspBlockedOrigin(violation.blockedUri)\n const disposition = sanitizePart(violation.disposition || 'report', 16)\n const id = [\n day,\n options.app,\n sanitizePart(directive, 64),\n disposition,\n origin,\n ].join('|')\n // Belt and braces — the AGL-1771 contract for anything handed to\n // `.doc()`. The sanitizer above should make this unreachable.\n if (!isDocumentId(id)) continue\n\n const existing = grouped.get(id)\n if (existing) {\n existing.n += 1\n continue\n }\n\n // Distinct-origin cap, per (day x app x directive). Known pairs keep\n // counting; new pairs beyond the cap are dropped — bounded documents\n // is the property, not perfect counts.\n const groupKey = `${day}|${options.app}|${directive}`\n if (mintTracker.size > MAX_TRACKED_GROUPS) mintTracker.clear()\n let minted = mintTracker.get(groupKey)\n if (!minted) {\n minted = new Set()\n mintTracker.set(groupKey, minted)\n }\n const pair = `${disposition}|${origin}`\n if (!minted.has(pair)) {\n if (minted.size >= MAX_DISTINCT_ORIGINS_PER_DAY) continue\n minted.add(pair)\n }\n\n grouped.set(id, { n: 1, directive, origin, violation })\n }\n if (!grouped.size) return 0\n\n // Write budget: fixed one-minute window per instance.\n if (now - budgetWindowStartMs >= 60_000) {\n budgetWindowStartMs = now\n budgetUsed = 0\n }\n const room = WRITE_BUDGET_PER_MINUTE - budgetUsed\n if (room <= 0) return 0\n const entries = [...grouped.entries()].slice(0, room)\n budgetUsed += entries.length\n\n const firestore =\n options.firestore ??\n // Lazily resolved so this module stays inert at load: route specs and\n // this module's own spec inject a fake, and nothing initializes\n // firebase-admin just by importing the barrel.\n (await import('./firebase-admin')).firebaseAdmin.app().firestore()\n\n await Promise.all(\n entries.map(([id, { n, directive, origin, violation }]) =>\n firestore\n .collection(CSP_AGGREGATE_COLLECTION)\n .doc(id)\n .set(\n {\n day,\n app: options.app,\n directive,\n origin,\n disposition: sanitizePart(violation.disposition || 'report', 16),\n count: FieldValue.increment(n),\n lastSeenMs: now,\n // Samples, not keys: enough to find the surface, no power to\n // mint documents. Already clamped by the parser; clamped again\n // because this module's contract should not depend on that.\n lastPath: violation.documentPath.slice(0, 200),\n ...(options.site ? { lastSite: options.site.slice(0, 253) } : {}),\n // Timestamp-typed via Date — Firestore TTL refuses numbers.\n expiresAt: new Date(\n dayStartMs + CSP_AGGREGATE_RETENTION_DAYS * 86_400_000,\n ),\n },\n { merge: true },\n ),\n ),\n )\n return entries.length\n } catch (error) {\n // Fail-soft: the aggregate is an observer, and `console.error` rather\n // than `console.warn` on purpose — the collectors' own log lines are\n // warns, and their specs count warns.\n console.error(\n JSON.stringify({\n tag: 'AGL-1799:csp-aggregate',\n error: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n"],"names":["FieldValue","isDocumentId","CSP_AGGREGATE_COLLECTION","CSP_AGGREGATE_RETENTION_DAYS","MAX_DISTINCT_ORIGINS_PER_DAY","WRITE_BUDGET_PER_MINUTE","KNOWN_DIRECTIVES","Set","mintTracker","Map","MAX_TRACKED_GROUPS","budgetWindowStartMs","budgetUsed","resetCspAggregateStateForTests","clear","sanitizePart","value","max","cleaned","toLowerCase","replace","slice","cspBlockedOrigin","blockedUri","URL","host","cspAggregateDay","nowMs","Date","toISOString","normalizeDirective","directive","has","recordCspViolations","violations","options","length","now","day","dayStartMs","parse","grouped","violation","effectiveDirective","origin","disposition","id","app","join","existing","get","n","groupKey","size","minted","set","pair","add","room","entries","firestore","firebaseAdmin","Promise","all","map","collection","doc","count","increment","lastSeenMs","lastPath","documentPath","site","lastSite","expiresAt","merge","error","console","JSON","stringify","tag","String"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/csp-aggregate.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 * Durable CSP-violation aggregates (AGL-1799).\n *\n * The collectors were delivering reports into a sink that forgets: the Vercel\n * runtime log retains ~60 minutes and there is no log drain, so AGL-1702's\n * \"a week of signed-in traffic\" and AGL-1726's \"a business week of visitor\n * traffic\" were gated on evidence that structurally could not exist. This\n * module is the durable half — the log lines stay exactly as they were (they\n * are still the right tool for the last hour), and every accepted violation\n * ALSO compounds into a Firestore counter that holds for the retention window.\n *\n * ## Shape: counters, never report bodies\n *\n * One document per (UTC day x app x directive x disposition x blocked origin),\n * with `FieldValue.increment` — NOT one document per report. AGL-523 made a\n * deliberate choice not to write report bodies to Firestore from an\n * unauthenticated route, and AGL-1799 argued the distinction explicitly: \"a\n * counter with a bounded key space is a different proposition from storing\n * report bodies\". The bound is what this module is careful about, because\n * every key ingredient except the day is attacker-influenced:\n *\n * - the DIRECTIVE is normalized into a closed set (a real browser can only\n * ever send a spec directive; anything else lands in `other` rather than\n * minting a document per invented directive);\n * - the ORIGIN is reduced to the host of `blocked-uri` (or its bare keyword\n * — `inline`, `eval`, `data`), sanitized to a fixed character set, and\n * capped: at most {@link MAX_DISTINCT_ORIGINS_PER_DAY} distinct origins per\n * (day x app x directive) may mint documents per instance per day. Beyond\n * that, new origins are dropped while known ones keep counting — a flood\n * of unique URLs (the AGL-1769 lesson: never let request content mint\n * unbounded documents) saturates the cap instead of the collection;\n * - WRITES are budgeted per instance per minute, so a POST flood cannot turn\n * an unauthenticated beacon into a Firestore write bill even against\n * already-minted documents.\n *\n * Every cap here is per instance, like the tenant collector's `keyStore`: the\n * fleet-wide worst case is (instances x cap), which is bounded and small,\n * and an instance recycle merely re-counts an origin against a fresh cap —\n * the merge write is idempotent about which document it lands in.\n *\n * ## Ids are attacker-derived, so they go through `isDocumentId`\n *\n * Key parts are sanitized to `[a-z0-9.:_-]` and joined with `|`, then the\n * whole id is checked with {@link isDocumentId} (the AGL-1771 predicate —\n * imported from its leaf, `./document-id`, which imports nothing). Belt and\n * braces: the sanitizer already removes `/` and the joiner makes a reserved\n * `__x__` form impossible, but the predicate is the contract and it is\n * cheaper to check than to argue.\n *\n * ## Retention is TTL, not a sweep\n *\n * `expiresAt` (a Timestamp — TTL refuses numbers) is set\n * {@link CSP_AGGREGATE_RETENTION_DAYS} days past the day bucket. The TTL\n * policy is a gcloud action plus a `fieldOverrides` entry in\n * `cloud/firebase-firestore.indexes.json` — see\n * docs/FIRESTORE_MANUAL_CONFIG.md (AGL-1793): the entry ships with this\n * module and the gcloud enable is owed at deploy time.\n *\n * ## Fail-soft, like everything on this path\n *\n * A collector must never turn an observed problem into a served one. Every\n * failure here — no credential, Firestore down, a hostile id that survives\n * sanitization — drops the aggregate (one `console.error` line, tagged) and\n * returns. The log lines the collectors already emit are unaffected.\n *\n * No client ever reads or writes this collection: there is no rules `match`\n * for it, and Firestore denies what nothing matches, so the Admin SDK is the\n * only path in. The staff read-back is `/api/admin/csp-reports` on the\n * console.\n *\n * ## The search tokens\n *\n * The staff table's search reads `searchTokens` with `array-contains`\n * (AGL-3321), so every write stamps it, through {@link cspSearchTokens}. It\n * is derived from the counter's KEY — origin, directive, app — which is the\n * document id and never changes, so the array is the same on every write to\n * one document and a merge cannot leave it describing something else.\n * `tools/scripts/backfill-csp-search-tokens.mjs` stamps the counters written\n * before it, held to `csp-search-tokens.fixtures.json` beside it.\n */\n\nimport { FieldValue } from 'firebase-admin/firestore'\nimport type { CspViolation } from '@aglyn/aglyn/app-utils/csp-report'\nimport { nameSearchTokens } from '@aglyn/aglyn/app-utils/name-search'\nimport { isDocumentId } from './document-id'\n\n/** Top-level collection of counter documents. Server-only; no rules match. */\nexport const CSP_AGGREGATE_COLLECTION = 'cspViolationDaily'\n\n/**\n * Long enough that \"a week of traffic\" (AGL-1702 condition 1, AGL-1726\n * condition 3) is readable with room to be read late, short enough that the\n * collection stays a working set rather than an archive.\n */\nexport const CSP_AGGREGATE_RETENTION_DAYS = 60\n\n/**\n * Distinct blocked origins that may mint documents per (day x app x\n * directive), per instance. A legitimate policy violates against a handful of\n * hosts; thirty is generous headroom for a real regression while keeping a\n * flood of attacker-invented origins from minting unbounded documents.\n */\nexport const MAX_DISTINCT_ORIGINS_PER_DAY = 30\n\n/**\n * Aggregate writes allowed per instance per minute. Distinct-origin capping\n * bounds document COUNT; this bounds write OPS, which are billed even when\n * they land on existing documents. Sized far above what real browsers on one\n * instance produce and far below what makes a flood expensive.\n */\nconst WRITE_BUDGET_PER_MINUTE = 120\n\n/**\n * The directives a browser can actually report, per the CSP3 registry. An\n * unrecognized directive is a non-browser caller (or a future spec revision)\n * and shares one bucket rather than minting its own key space.\n */\nconst KNOWN_DIRECTIVES = new Set([\n 'base-uri',\n 'block-all-mixed-content',\n 'child-src',\n 'connect-src',\n 'default-src',\n 'font-src',\n 'form-action',\n 'frame-ancestors',\n 'frame-src',\n 'img-src',\n 'manifest-src',\n 'media-src',\n 'object-src',\n 'prefetch-src',\n 'script-src',\n 'script-src-attr',\n 'script-src-elem',\n 'style-src',\n 'style-src-attr',\n 'style-src-elem',\n 'upgrade-insecure-requests',\n 'worker-src',\n])\n\n/**\n * Per-instance mint tracker: (day|app|directive) to the origin|disposition\n * pairs that have been allowed to mint documents. Cleared wholesale at the\n * cap, like the tenant collector's `keyStore`: the failure mode of clearing\n * is a brief widening of the mint cap, strictly better than an unbounded map\n * fed by attacker-chosen keys.\n */\nconst mintTracker = new Map<string, Set<string>>()\nconst MAX_TRACKED_GROUPS = 512\n\n/** Per-instance write budget window. */\nlet budgetWindowStartMs = 0\nlet budgetUsed = 0\n\n/** Test seam: module-scope state would otherwise leak across cases. */\nexport function resetCspAggregateStateForTests(): void {\n mintTracker.clear()\n budgetWindowStartMs = 0\n budgetUsed = 0\n}\n\n/** One id/field component: lowercase, fixed charset, never empty. */\nfunction sanitizePart(value: string, max: number): string {\n const cleaned = value\n .toLowerCase()\n .replace(/[^a-z0-9.:_-]+/g, '-')\n .slice(0, max)\n return cleaned || 'unknown'\n}\n\n/**\n * `blocked-uri` reduced to the coarsest thing worth counting: the HOST for a\n * URL, the bare keyword (`inline`, `eval`, `data`, ...) otherwise. Paths are\n * deliberately dropped — twelve chunk URLs off one CDN are one origin-level\n * fact (the AGL-1779 shape), and a path would both explode the key space and\n * hand the id to whoever controls the URL.\n */\nexport function cspBlockedOrigin(blockedUri: string): string {\n if (!blockedUri) return 'none'\n try {\n return sanitizePart(new URL(blockedUri).host, 120)\n } catch {\n return sanitizePart(blockedUri, 40)\n }\n}\n\n/** The written field the staff CSP table's search reads. */\nexport const CSP_SEARCH_TOKENS_PATH = 'searchTokens'\n\n/**\n * The search tokens one counter carries: the word prefixes of its blocked\n * origin, its directive and its app — each whole, and each run of letters\n * and digits in it — so `googletagmanager`, `cdn.example.com`, `inline`,\n * `script`, `elem` and `tenant` all find the counters they name. The\n * lower-casing and the prefixes are `nameSearchTokens`, the normalizer the\n * list query asks with. Not the sampled site and path: they change with every\n * report, and a merge would leave tokens for a site the row no longer shows.\n */\nexport function cspSearchTokens(key: {\n origin?: unknown\n directive?: unknown\n app?: unknown\n}): string[] {\n const words = [key.origin, key.directive, key.app]\n .map((value) => (typeof value === 'string' ? value.trim().toLowerCase() : ''))\n .filter(Boolean)\n .flatMap((value) => [value, ...value.split(/[^a-z0-9]+/)])\n .filter(Boolean)\n return nameSearchTokens(words.join(' '))\n}\n\n/** UTC day bucket, `YYYY-MM-DD`. */\nexport function cspAggregateDay(nowMs: number): string {\n return new Date(nowMs).toISOString().slice(0, 10)\n}\n\nfunction normalizeDirective(directive: string): string {\n return KNOWN_DIRECTIVES.has(directive) ? directive : 'other'\n}\n\nexport interface RecordCspViolationsOptions {\n /** Which collector this came through — part of the counter key. */\n app: 'console' | 'tenant'\n /**\n * The reporting site (tenant only), REQUEST-derived and already clamped by\n * the caller. Stored as a sample field, never in the id: distinct sites\n * must not multiply the key space.\n */\n site?: string\n now?: number\n /** Injectable for tests; defaults to the Admin SDK's Firestore. */\n firestore?: any\n}\n\n/**\n * Compound `violations` into the daily counters. Never throws; returns how\n * many documents were written (0 on any failure, budget exhaustion included).\n */\nexport async function recordCspViolations(\n violations: readonly CspViolation[],\n options: RecordCspViolationsOptions,\n): Promise<number> {\n try {\n if (!violations.length) return 0\n const now = options.now ?? Date.now()\n const day = cspAggregateDay(now)\n const dayStartMs = Date.parse(`${day}T00:00:00.000Z`)\n\n // Group first: N occurrences of one key in a batch are ONE write carrying\n // increment(N), so a page that repeats itself costs one op, not N.\n const grouped = new Map<\n string,\n { n: number; directive: string; origin: string; violation: CspViolation }\n >()\n for (const violation of violations) {\n const directive = normalizeDirective(violation.effectiveDirective)\n const origin = cspBlockedOrigin(violation.blockedUri)\n const disposition = sanitizePart(violation.disposition || 'report', 16)\n const id = [\n day,\n options.app,\n sanitizePart(directive, 64),\n disposition,\n origin,\n ].join('|')\n // Belt and braces — the AGL-1771 contract for anything handed to\n // `.doc()`. The sanitizer above should make this unreachable.\n if (!isDocumentId(id)) continue\n\n const existing = grouped.get(id)\n if (existing) {\n existing.n += 1\n continue\n }\n\n // Distinct-origin cap, per (day x app x directive). Known pairs keep\n // counting; new pairs beyond the cap are dropped — bounded documents\n // is the property, not perfect counts.\n const groupKey = `${day}|${options.app}|${directive}`\n if (mintTracker.size > MAX_TRACKED_GROUPS) mintTracker.clear()\n let minted = mintTracker.get(groupKey)\n if (!minted) {\n minted = new Set()\n mintTracker.set(groupKey, minted)\n }\n const pair = `${disposition}|${origin}`\n if (!minted.has(pair)) {\n if (minted.size >= MAX_DISTINCT_ORIGINS_PER_DAY) continue\n minted.add(pair)\n }\n\n grouped.set(id, { n: 1, directive, origin, violation })\n }\n if (!grouped.size) return 0\n\n // Write budget: fixed one-minute window per instance.\n if (now - budgetWindowStartMs >= 60_000) {\n budgetWindowStartMs = now\n budgetUsed = 0\n }\n const room = WRITE_BUDGET_PER_MINUTE - budgetUsed\n if (room <= 0) return 0\n const entries = [...grouped.entries()].slice(0, room)\n budgetUsed += entries.length\n\n const firestore =\n options.firestore ??\n // Lazily resolved so this module stays inert at load: route specs and\n // this module's own spec inject a fake, and nothing initializes\n // firebase-admin just by importing the barrel.\n (await import('./firebase-admin')).firebaseAdmin.app().firestore()\n\n await Promise.all(\n entries.map(([id, { n, directive, origin, violation }]) =>\n firestore\n .collection(CSP_AGGREGATE_COLLECTION)\n .doc(id)\n .set(\n {\n day,\n app: options.app,\n directive,\n origin,\n disposition: sanitizePart(violation.disposition || 'report', 16),\n count: FieldValue.increment(n),\n [CSP_SEARCH_TOKENS_PATH]: cspSearchTokens({\n origin,\n directive,\n app: options.app,\n }),\n lastSeenMs: now,\n // Samples, not keys: enough to find the surface, no power to\n // mint documents. Already clamped by the parser; clamped again\n // because this module's contract should not depend on that.\n lastPath: violation.documentPath.slice(0, 200),\n ...(options.site ? { lastSite: options.site.slice(0, 253) } : {}),\n // Timestamp-typed via Date — Firestore TTL refuses numbers.\n expiresAt: new Date(\n dayStartMs + CSP_AGGREGATE_RETENTION_DAYS * 86_400_000,\n ),\n },\n { merge: true },\n ),\n ),\n )\n return entries.length\n } catch (error) {\n // Fail-soft: the aggregate is an observer, and `console.error` rather\n // than `console.warn` on purpose — the collectors' own log lines are\n // warns, and their specs count warns.\n console.error(\n JSON.stringify({\n tag: 'AGL-1799:csp-aggregate',\n error: String(error).slice(0, 200),\n }),\n )\n return 0\n }\n}\n"],"names":["FieldValue","nameSearchTokens","isDocumentId","CSP_AGGREGATE_COLLECTION","CSP_AGGREGATE_RETENTION_DAYS","MAX_DISTINCT_ORIGINS_PER_DAY","WRITE_BUDGET_PER_MINUTE","KNOWN_DIRECTIVES","Set","mintTracker","Map","MAX_TRACKED_GROUPS","budgetWindowStartMs","budgetUsed","resetCspAggregateStateForTests","clear","sanitizePart","value","max","cleaned","toLowerCase","replace","slice","cspBlockedOrigin","blockedUri","URL","host","CSP_SEARCH_TOKENS_PATH","cspSearchTokens","key","words","origin","directive","app","map","trim","filter","Boolean","flatMap","split","join","cspAggregateDay","nowMs","Date","toISOString","normalizeDirective","has","recordCspViolations","violations","options","length","now","day","dayStartMs","parse","grouped","violation","effectiveDirective","disposition","id","existing","get","n","groupKey","size","minted","set","pair","add","room","entries","firestore","firebaseAdmin","Promise","all","collection","doc","count","increment","lastSeenMs","lastPath","documentPath","site","lastSite","expiresAt","merge","error","console","JSON","stringify","tag","String"],"mappings":"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"}
|