@aglyn/tenant-data-admin 1.0.0-beta.198 → 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/collection-live-pages.d.ts +83 -0
- package/src/lib/server/collection-live-pages.js +198 -0
- package/src/lib/server/collection-live-pages.js.map +1 -0
- 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/live-page-usage.d.ts +33 -0
- package/src/lib/server/live-page-usage.js +61 -1
- package/src/lib/server/live-page-usage.js.map +1 -1
- 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/consent-group-carry.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 * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/consent-group-carry.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 * CARRYING A SITE'S REFUSALS ONTO A SITE THAT IS ABOUT TO STOP READING THEM\n * (AGL-3320).\n *\n * Three per-site stores are read across the CURRENT consent group by every\n * send path (`email-suppression.ts`, `email-marketing-gate.ts`), so a site\n * leaving a group stops seeing its former siblings' — and they stop seeing\n * its. Before the declaration flips, each carry `(to ← from)` of the plan\n * copies `from`'s refusals onto `to`:\n *\n * - **C1, the suppression list** (`hosts/{h}/suppressions/{key}`). A row\n * `to` does not have is CREATED with `from`'s fields and provenance; a row\n * `to` has is never touched. `reason` is kept, so only an unsubscribe stays\n * liftable by the preference page's resubscribe, and a group-wide\n * resubscribe lifts a carried row like an original.\n * - **C2, the topic opt-outs** (`hosts/{h}/topicOptOuts/{key}`). Every\n * stream `from` has LEFT, and `to` has not, becomes an opt-out on `to`\n * dated when the person left `from`. `to`'s other entries — its own\n * confirmations, its own opt-outs — are kept as they are.\n * - **C3, the pace** (`hosts/{h}/emailFrequency/{key}`). The choice made\n * most recently wins, as `groupPace` reads it within a group, and a site\n * keeping a slower pace learns when `from` last mailed the person, so the\n * separation cannot loosen the pace they asked for. The send counters are\n * never touched.\n *\n * ## One page per call, and every page idempotent\n *\n * The executor calls this until it answers `done`, handing back the cursor.\n * A page reads `from`'s documents in id order and `to`'s matching documents\n * in one `getAll`, and writes only what `to` lacks — so a page re-run after a\n * crash, a catch-up pass and the sweep all write nothing that was already\n * carried. A write that raced another writer (a `lastUpdateTime` precondition\n * or a create that found the document) is re-read and decided again once;\n * failing twice throws, and the executor retries the page.\n *\n * `sinceMs` narrows a page to what changed since then — the catch-up, which\n * reads by `suppressedAt`, `updatedAt` and `cadenceSetAtMs`. A row written\n * without one of those is left to the sweep's full pass.\n */\n\nimport {\n readTopicSubscriptionState,\n TOPIC_OPT_OUTS_SUBCOLLECTION,\n type TopicSubscriptionEntry,\n} from '@aglyn/aglyn/app-utils/email-topics'\nimport type { ConsentGroupCarry } from '@aglyn/aglyn/app-utils/consent-group-change'\nimport { FieldPath, FieldValue, Timestamp } from 'firebase-admin/firestore'\nimport { EMAIL_FREQUENCY_SUBCOLLECTION } from './email-marketing-gate'\nimport { HOST_SUPPRESSIONS_SUBCOLLECTION } from './email-suppression'\n\n/** The three stores a carry copies, as the job counts them. */\nexport type ConsentGroupCarryStore = 'siteSuppressions' | 'topicOptOuts' | 'paces'\n\nexport const CONSENT_GROUP_CARRY_STORES: readonly ConsentGroupCarryStore[] = [\n 'siteSuppressions',\n 'topicOptOuts',\n 'paces',\n]\n\n/** Each store's subcollection under `hosts/{hostId}`. */\nexport const CONSENT_GROUP_CARRY_SUBCOLLECTIONS: Readonly<Record<ConsentGroupCarryStore, string>> = {\n siteSuppressions: HOST_SUPPRESSIONS_SUBCOLLECTION,\n topicOptOuts: TOPIC_OPT_OUTS_SUBCOLLECTION,\n paces: EMAIL_FREQUENCY_SUBCOLLECTION,\n}\n\n/** The field a catch-up reads each store by, and whether it is a timestamp. */\nconst CATCH_UP_FIELDS: Readonly<Record<ConsentGroupCarryStore, { field: string; timestamp: boolean }>> = {\n siteSuppressions: { field: 'suppressedAt', timestamp: true },\n topicOptOuts: { field: 'updatedAt', timestamp: true },\n paces: { field: 'cadenceSetAtMs', timestamp: false },\n}\n\n/**\n * The suppression row's fields a carry copies. Everything else a row could\n * carry is either the carry's own provenance or not a fact about the refusal.\n */\nconst SUPPRESSION_ROW_FIELDS = [\n 'reason',\n 'email',\n // The address's search tokens travel with it (AGL-3321).\n 'emailTokens',\n 'createdAt',\n 'suppressedAt',\n 'note',\n 'suppressedByUid',\n 'campaignId',\n 'topicId',\n] as const\n\n/** Documents one page reads from the source site. */\nexport const CONSENT_GROUP_CARRY_PAGE = 300\n\nexport interface ConsentGroupCarryPage {\n /** The source site has nothing after the cursor. */\n done: boolean\n /** Where the next page starts, or `null` once `done`. */\n cursor: string | null\n /** Source documents read. */\n read: number\n /** Documents written on the receiving site (counted, not written, on a dry run). */\n written: number\n}\n\nexport interface CarryConsentGroupRefusalsOptions {\n firestore: FirebaseFirestore.Firestore\n store: ConsentGroupCarryStore\n carry: ConsentGroupCarry\n changeId: string\n cursor: string | null\n /** Only documents changed at or after this instant — the catch-up. */\n sinceMs?: number | null\n pageSize?: number\n dryRun?: boolean\n}\n\ninterface Cursor {\n id: string\n /** The catch-up field's value on the last document read. */\n at?: { seconds: number; nanoseconds: number } | number\n}\n\nfunction readCursor(raw: string | null): Cursor | null {\n if (!raw) return null\n try {\n const parsed = JSON.parse(raw) as Cursor\n return typeof parsed?.id === 'string' ? parsed : null\n } catch {\n return null\n }\n}\n\n/** The error codes that mean \"somebody wrote this document since I read it\". */\nconst RACED = new Set([5, 6, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\n/** Milliseconds from a stored number or timestamp, or `null`. */\nfunction storedMs(value: unknown): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null\n const toMillis = (value as { toMillis?: () => number } | null)?.toMillis\n return typeof toMillis === 'function' ? toMillis.call(value) : null\n}\n\n/**\n * When the site last mailed the person, as the gate reads it: the stored\n * instant, or the newest entry of a window written before it existed.\n */\nfunction lastSentMs(data: Record<string, unknown> | undefined): number | null {\n if (!data) return null\n const stored = Number(data['lastSentAtMs'])\n if (Number.isFinite(stored) && stored > 0) return stored\n const window = Array.isArray(data['sentAtMs'])\n ? (data['sentAtMs'] as unknown[]).map(Number).filter(Number.isFinite)\n : []\n return window.length ? Math.max(...window) : null\n}\n\n/**\n * What `to` must become for one source document, or `null` when it already\n * honors everything `from` does. `create` says the document is new.\n */\ntype Decision = { create: boolean; data: Record<string, unknown> } | null\n\nfunction decideSuppression(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (target.exists) return null\n const row = source.data() ?? {}\n const data: Record<string, unknown> = {}\n for (const field of SUPPRESSION_ROW_FIELDS) {\n if (field in row) data[field] = row[field]\n }\n data['carriedFromHostId'] = carry.fromHostId\n data['carriedByChangeId'] = changeId\n data['carriedAt'] = FieldValue.serverTimestamp()\n return { create: true, data }\n}\n\nfunction decideTopics(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const sourceTopics = (source.get('topics') ?? {}) as Record<string, TopicSubscriptionEntry>\n const targetTopics = ((target.exists ? target.get('topics') : null) ?? {}) as Record<\n string,\n TopicSubscriptionEntry & Record<string, unknown>\n >\n const next: Record<string, unknown> = { ...targetTopics }\n let changed = false\n for (const [topicId, entry] of Object.entries(sourceTopics)) {\n if (readTopicSubscriptionState(entry) !== 'opted-out') continue\n if (readTopicSubscriptionState(targetTopics[topicId]) === 'opted-out') continue\n next[topicId] = {\n ...(targetTopics[topicId] ?? {}),\n optedOutAt: entry.optedOutAt,\n resubscribedAt: null,\n carriedFromHostId: carry.fromHostId,\n }\n changed = true\n }\n if (!changed) return null\n const email = (target.exists ? target.get('email') : null) ?? source.get('email') ?? null\n return {\n create: !target.exists,\n data: {\n email,\n topics: next,\n updatedAt: FieldValue.serverTimestamp(),\n ...(target.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),\n },\n }\n}\n\nfunction decidePace(\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n): Decision {\n const from = source.data() ?? {}\n const to = target.exists ? (target.data() ?? {}) : undefined\n const patch: Record<string, unknown> = {}\n const sourceChoice = from['cadence']\n const targetChoice = to?.['cadence']\n const sourceSetAt = Number(from['cadenceSetAtMs'])\n const targetSetAt = Number(to?.['cadenceSetAtMs'])\n const carried =\n sourceChoice != null &&\n (targetChoice == null ||\n (Number.isFinite(targetSetAt) ? targetSetAt : 0) <\n (Number.isFinite(sourceSetAt) ? sourceSetAt : 0))\n if (carried) {\n patch['cadence'] = sourceChoice\n if (Number.isFinite(sourceSetAt)) patch['cadenceSetAtMs'] = sourceSetAt\n patch['cadenceCarriedFromHostId'] = carry.fromHostId\n }\n // The pace in force afterwards decides whether the last send matters: `all`\n // sends as they come and never asks when the last one went.\n const choice = carried ? sourceChoice : targetChoice\n if (choice != null && choice !== 'all') {\n const sourceLast = lastSentMs(from)\n const targetLast = lastSentMs(to)\n if (sourceLast !== null && (targetLast === null || sourceLast > targetLast)) {\n patch['lastSentAtMs'] = sourceLast\n }\n }\n if (!Object.keys(patch).length) return null\n return {\n create: !target.exists,\n data: target.exists ? patch : { email: from['email'] ?? null, ...patch },\n }\n}\n\nfunction decide(\n store: ConsentGroupCarryStore,\n source: FirebaseFirestore.DocumentSnapshot,\n target: FirebaseFirestore.DocumentSnapshot,\n carry: ConsentGroupCarry,\n changeId: string,\n): Decision {\n if (store === 'siteSuppressions') return decideSuppression(source, target, carry, changeId)\n if (store === 'topicOptOuts') return decideTopics(source, target, carry)\n return decidePace(source, target, carry)\n}\n\n/**\n * One page of one store of one carry. See the module note for what each\n * store copies and why a re-run writes nothing.\n */\nexport async function carryConsentGroupRefusals(\n options: CarryConsentGroupRefusalsOptions,\n): Promise<ConsentGroupCarryPage> {\n const { firestore, store, carry, changeId } = options\n const subcollection = CONSENT_GROUP_CARRY_SUBCOLLECTIONS[store]\n const sourceList = firestore.collection('hosts').doc(carry.fromHostId).collection(subcollection)\n const targetList = firestore.collection('hosts').doc(carry.toHostId).collection(subcollection)\n const limit = options.pageSize ?? CONSENT_GROUP_CARRY_PAGE\n const cursor = readCursor(options.cursor)\n const catchUp = CATCH_UP_FIELDS[store]\n const since = options.sinceMs ?? null\n\n let query: FirebaseFirestore.Query = sourceList\n if (since !== null) {\n query = query\n .where(catchUp.field, '>=', catchUp.timestamp ? Timestamp.fromMillis(since) : since)\n .orderBy(catchUp.field)\n .orderBy(FieldPath.documentId())\n if (cursor && cursor.at !== undefined) {\n const at =\n typeof cursor.at === 'number' ? cursor.at : new Timestamp(cursor.at.seconds, cursor.at.nanoseconds)\n query = query.startAfter(at, cursor.id)\n }\n } else {\n query = query.orderBy(FieldPath.documentId())\n if (cursor) query = query.startAfter(cursor.id)\n }\n const page = await query.limit(limit).get()\n if (page.empty) return { done: true, cursor: null, read: 0, written: 0 }\n\n const targets = await firestore.getAll(...page.docs.map((doc) => targetList.doc(doc.id)))\n const decisions = page.docs.map((source, index) => ({\n source,\n target: targets[index],\n decision: decide(store, source, targets[index], carry, changeId),\n }))\n const due = decisions.filter((entry) => entry.decision !== null)\n const written = options.dryRun\n ? due.length\n : await writeDecisions(firestore, targetList, store, carry, changeId, due)\n\n const last = page.docs[page.docs.length - 1]\n const next: Cursor = { id: last.id }\n if (since !== null) {\n const value = last.get(catchUp.field)\n next.at =\n value instanceof Timestamp\n ? { seconds: value.seconds, nanoseconds: value.nanoseconds }\n : (storedMs(value) ?? 0)\n }\n const done = page.size < limit\n return { done, cursor: done ? null : JSON.stringify(next), read: page.size, written }\n}\n\n/**\n * Writes one page's decisions through a `BulkWriter`, each guarded against\n * the document having moved since it was read, and decides the ones that\n * raced once more from a fresh read.\n */\nasync function writeDecisions(\n firestore: FirebaseFirestore.Firestore,\n targetList: FirebaseFirestore.CollectionReference,\n store: ConsentGroupCarryStore,\n carry: ConsentGroupCarry,\n changeId: string,\n due: Array<{\n source: FirebaseFirestore.DocumentSnapshot\n target: FirebaseFirestore.DocumentSnapshot\n decision: Decision\n }>,\n): Promise<number> {\n let written = 0\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map(({ source, target, decision }) => {\n const ref = targetList.doc(source.id)\n const write = (decision as NonNullable<Decision>).create\n ? writer.create(ref, (decision as NonNullable<Decision>).data)\n : writer.update(ref, (decision as NonNullable<Decision>).data, {\n lastUpdateTime: target.updateTime as FirebaseFirestore.Timestamp,\n })\n return write.then(\n () => ({ source, error: null as unknown }),\n (error: unknown) => ({ source, error: error ?? new Error('The write failed') }),\n )\n })\n await writer.close()\n const settled = await Promise.all(outcomes)\n const retry: FirebaseFirestore.DocumentSnapshot[] = []\n for (const { source, error } of settled) {\n if (error === null) {\n written += 1\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n // The row a suppression carry would create exists now: `to` has its own.\n if (store === 'siteSuppressions' && code === 6) continue\n retry.push(source)\n }\n if (!retry.length) return written\n const fresh = await firestore.getAll(...retry.map((source) => targetList.doc(source.id)))\n due = retry\n .map((source, index) => ({\n source,\n target: fresh[index],\n decision: decide(store, source, fresh[index], carry, changeId),\n }))\n .filter((entry) => entry.decision !== null)\n }\n if (due.length) {\n throw new Error(\n `[consent-group-carry] ${due.length} document(s) on ${carry.toHostId} kept changing under the carry`,\n )\n }\n return written\n}\n"],"names":["readTopicSubscriptionState","TOPIC_OPT_OUTS_SUBCOLLECTION","FieldPath","FieldValue","Timestamp","EMAIL_FREQUENCY_SUBCOLLECTION","HOST_SUPPRESSIONS_SUBCOLLECTION","CONSENT_GROUP_CARRY_STORES","CONSENT_GROUP_CARRY_SUBCOLLECTIONS","siteSuppressions","topicOptOuts","paces","CATCH_UP_FIELDS","field","timestamp","SUPPRESSION_ROW_FIELDS","CONSENT_GROUP_CARRY_PAGE","readCursor","raw","parsed","JSON","parse","id","RACED","Set","codeOf","error","code","storedMs","value","Number","isFinite","toMillis","call","lastSentMs","data","stored","window","Array","isArray","map","filter","length","Math","max","decideSuppression","source","target","carry","changeId","exists","row","fromHostId","serverTimestamp","create","decideTopics","sourceTopics","get","targetTopics","next","changed","topicId","entry","Object","entries","optedOutAt","resubscribedAt","carriedFromHostId","email","topics","updatedAt","createdAt","decidePace","from","to","undefined","patch","sourceChoice","targetChoice","sourceSetAt","targetSetAt","carried","choice","sourceLast","targetLast","keys","decide","store","carryConsentGroupRefusals","options","firestore","subcollection","sourceList","collection","doc","targetList","toHostId","limit","pageSize","cursor","catchUp","since","sinceMs","query","where","fromMillis","orderBy","documentId","at","seconds","nanoseconds","startAfter","page","empty","done","read","written","targets","getAll","docs","decisions","index","decision","due","dryRun","writeDecisions","last","size","stringify","round","writer","bulkWriter","outcomes","ref","write","update","lastUpdateTime","updateTime","then","Error","close","settled","Promise","all","retry","has","push","fresh"],"mappings":"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"}
|
|
@@ -29,6 +29,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
29
29
|
* fixture barrel, and a helper that reached the planner only through the
|
|
30
30
|
* barrel would find `undefined` there and take the door down with it.
|
|
31
31
|
*/ import { COMPANY_CONTACTS_COUNT_FIELD, CONTACT_COMPANY_IDS_FIELD, planContactCompanyLink, readContactCompanyLink } from "@aglyn/aglyn/app-utils/crm";
|
|
32
|
+
import { restampCrmListFieldsAt } from "./crm-records.js";
|
|
32
33
|
import { contactFacetPath } from "@aglyn/aglyn/app-utils/contacts";
|
|
33
34
|
import { FieldValue } from "firebase-admin/firestore";
|
|
34
35
|
/**
|
|
@@ -99,6 +100,8 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
99
100
|
batch.update(companiesRef.doc(count.companyId), companyContactsCountFields(count.delta));
|
|
100
101
|
}
|
|
101
102
|
await batch.commit();
|
|
103
|
+
// The company is what the Contacts list filters by (AGL-3321).
|
|
104
|
+
await restampCrmListFieldsAt(contactRef, 'contacts');
|
|
102
105
|
return plan;
|
|
103
106
|
}
|
|
104
107
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-company-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The contact–company link as the Admin SDK writes it (AGL-2613).\n *\n * The console's writers apply `planContactCompanyLink` with the client\n * SDK's sentinels; the server doors — the capture that links on domain, the\n * create route, a lead's conversion, the upsert carrying a door's company —\n * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,\n * and this file is the second: nothing here decides whether an id leaves the\n * mirror, it only spells the answer in Admin terms.\n *\n * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,\n * for the reason `upsert-contact.ts` gives: the door's specs substitute a\n * fixture barrel, and a helper that reached the planner only through the\n * barrel would find `undefined` there and take the door down with it.\n */\n\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n type ContactCompanyLinkPlan,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { contactFacetPath } from '@aglyn/aglyn/app-utils/contacts'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The plan's mirror change as the sentinel — or array — an Admin write takes.\n * Both an `update()` and a merge-`set()` accept these at the top level of\n * the document, which is where the mirror lives.\n */\nexport function contactCompanyMirrorValue(\n plan: ContactCompanyLinkPlan,\n): unknown | undefined {\n const change = plan.mirror\n if (!change) return undefined\n if (change.op === 'union') return FieldValue.arrayUnion(change.companyId)\n if (change.op === 'remove') return FieldValue.arrayRemove(change.companyId)\n return change.companyIds\n}\n\n/**\n * The contact's patch for a plan, in `update()` shape: the holder's facet by\n * DOTTED path — a nested object would replace every other holder's facet —\n * and the mirror at the top of the document.\n */\nexport function contactCompanyLinkFields(\n plan: ContactCompanyLinkPlan,\n groupId: string,\n): Record<string, unknown> {\n const mirror = contactCompanyMirrorValue(plan)\n return {\n [contactFacetPath(groupId, 'companyId')]: plan.companyId ?? FieldValue.delete(),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n }\n}\n\n/** The company's patch: its contacts count moved by `delta`. */\nexport function companyContactsCountFields(delta: number): Record<string, unknown> {\n return { [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(delta) }\n}\n\n/**\n * Move each company's count the plan names, one `update()` each, without\n * failing the caller.\n *\n * Separate writes rather than a batch, for the callers that cannot batch:\n * the upsert has already written the contact by the time it knows the plan\n * held, and a capture door must not fail because a company document was\n * deleted between the link and the count. A count that could not move is\n * logged and left; the company's own page takes the live aggregate, which\n * is what corrects a stale figure.\n */\nexport async function settleCompanyContactsCounts(\n companiesRef: FirebaseFirestore.CollectionReference | null,\n plan: ContactCompanyLinkPlan | null,\n): Promise<void> {\n if (!companiesRef || !plan?.counts.length) return\n await Promise.all(\n plan.counts.map((count) =>\n companiesRef\n .doc(count.companyId)\n .update(companyContactsCountFields(count.delta))\n .catch((error: unknown) => {\n console.error(\n 'contact company count could not move',\n count.companyId,\n count.delta,\n error,\n )\n }),\n ),\n )\n}\n\nexport interface WriteContactCompanyLinkOptions {\n firestore: FirebaseFirestore.Firestore\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The stored contact, BEFORE this write — what the planner reads the\n * previous link and the mirror off. `null` for a row the caller has just\n * created with no company on it.\n */\n contact: Record<string, unknown> | null | undefined\n companiesRef: FirebaseFirestore.CollectionReference\n groupId: string\n /** The company to link for this holder, or `null` to unlink. */\n companyId: string | null\n}\n\n/**\n * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as\n * ONE commit: the facet and the mirror on the contact, and the count on\n * each company the link moves. `null` when the document already said so.\n *\n * A batch rather than two writes because the count is a derived figure of\n * the mirror: a mirror that changed while the count did not is a company\n * list that says \"3\" over a page of four. `updatedAt` moves on the contact\n * — a link is an edit of the person's record — and not on the company,\n * whose list is ordered by its own edits and would otherwise reshuffle on\n * every capture.\n */\nexport async function writeContactCompanyLink(\n options: WriteContactCompanyLinkOptions,\n): Promise<ContactCompanyLinkPlan | null> {\n const { firestore, contactRef, contact, companiesRef, groupId, companyId } =\n options\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n companyId,\n )\n if (!plan) return null\n const batch = firestore.batch()\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n for (const count of plan.counts) {\n batch.update(\n companiesRef.doc(count.companyId),\n companyContactsCountFields(count.delta),\n )\n }\n await batch.commit()\n return plan\n}\n"],"names":["COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","planContactCompanyLink","readContactCompanyLink","contactFacetPath","FieldValue","contactCompanyMirrorValue","plan","change","mirror","undefined","op","arrayUnion","companyId","arrayRemove","companyIds","contactCompanyLinkFields","groupId","delete","companyContactsCountFields","delta","increment","settleCompanyContactsCounts","companiesRef","counts","length","Promise","all","map","count","doc","update","catch","error","console","writeContactCompanyLink","options","firestore","contactRef","contact","batch","updatedAt","serverTimestamp","commit"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED,SACEA,4BAA4B,EAC5BC,yBAAyB,EAEzBC,sBAAsB,EACtBC,sBAAsB,QACjB,6BAA4B;AACnC,SAASC,gBAAgB,QAAQ,kCAAiC;AAClE,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;CAIC,GACD,OAAO,SAASC,0BACdC,IAA4B;IAE5B,MAAMC,SAASD,KAAKE,MAAM;IAC1B,IAAI,CAACD,QAAQ,OAAOE;IACpB,IAAIF,OAAOG,EAAE,KAAK,SAAS,OAAON,WAAWO,UAAU,CAACJ,OAAOK,SAAS;IACxE,IAAIL,OAAOG,EAAE,KAAK,UAAU,OAAON,WAAWS,WAAW,CAACN,OAAOK,SAAS;IAC1E,OAAOL,OAAOO,UAAU;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBACdT,IAA4B,EAC5BU,OAAe;QAI6BV;IAF5C,MAAME,SAASH,0BAA0BC;IACzC,OAAO;QACL,CAACH,iBAAiBa,SAAS,aAAa,GAAEV,kBAAAA,KAAKM,SAAS,YAAdN,kBAAkBF,WAAWa,MAAM;OACzET,WAAWC,YAAY;QAAE,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-company-link.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The contact–company link as the Admin SDK writes it (AGL-2613).\n *\n * The console's writers apply `planContactCompanyLink` with the client\n * SDK's sentinels; the server doors — the capture that links on domain, the\n * create route, a lead's conversion, the upsert carrying a door's company —\n * apply the same plan with `FieldValue`'s. One planner, two sentinel sets,\n * and this file is the second: nothing here decides whether an id leaves the\n * mirror, it only spells the answer in Admin terms.\n *\n * Imported by module path rather than from the `@aglyn/aglyn/server` barrel,\n * for the reason `upsert-contact.ts` gives: the door's specs substitute a\n * fixture barrel, and a helper that reached the planner only through the\n * barrel would find `undefined` there and take the door down with it.\n */\n\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n type ContactCompanyLinkPlan,\n planContactCompanyLink,\n readContactCompanyLink,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { restampCrmListFieldsAt } from './crm-records'\nimport { contactFacetPath } from '@aglyn/aglyn/app-utils/contacts'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * The plan's mirror change as the sentinel — or array — an Admin write takes.\n * Both an `update()` and a merge-`set()` accept these at the top level of\n * the document, which is where the mirror lives.\n */\nexport function contactCompanyMirrorValue(\n plan: ContactCompanyLinkPlan,\n): unknown | undefined {\n const change = plan.mirror\n if (!change) return undefined\n if (change.op === 'union') return FieldValue.arrayUnion(change.companyId)\n if (change.op === 'remove') return FieldValue.arrayRemove(change.companyId)\n return change.companyIds\n}\n\n/**\n * The contact's patch for a plan, in `update()` shape: the holder's facet by\n * DOTTED path — a nested object would replace every other holder's facet —\n * and the mirror at the top of the document.\n */\nexport function contactCompanyLinkFields(\n plan: ContactCompanyLinkPlan,\n groupId: string,\n): Record<string, unknown> {\n const mirror = contactCompanyMirrorValue(plan)\n return {\n [contactFacetPath(groupId, 'companyId')]: plan.companyId ?? FieldValue.delete(),\n ...(mirror !== undefined ? { [CONTACT_COMPANY_IDS_FIELD]: mirror } : {}),\n }\n}\n\n/** The company's patch: its contacts count moved by `delta`. */\nexport function companyContactsCountFields(delta: number): Record<string, unknown> {\n return { [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(delta) }\n}\n\n/**\n * Move each company's count the plan names, one `update()` each, without\n * failing the caller.\n *\n * Separate writes rather than a batch, for the callers that cannot batch:\n * the upsert has already written the contact by the time it knows the plan\n * held, and a capture door must not fail because a company document was\n * deleted between the link and the count. A count that could not move is\n * logged and left; the company's own page takes the live aggregate, which\n * is what corrects a stale figure.\n */\nexport async function settleCompanyContactsCounts(\n companiesRef: FirebaseFirestore.CollectionReference | null,\n plan: ContactCompanyLinkPlan | null,\n): Promise<void> {\n if (!companiesRef || !plan?.counts.length) return\n await Promise.all(\n plan.counts.map((count) =>\n companiesRef\n .doc(count.companyId)\n .update(companyContactsCountFields(count.delta))\n .catch((error: unknown) => {\n console.error(\n 'contact company count could not move',\n count.companyId,\n count.delta,\n error,\n )\n }),\n ),\n )\n}\n\nexport interface WriteContactCompanyLinkOptions {\n firestore: FirebaseFirestore.Firestore\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The stored contact, BEFORE this write — what the planner reads the\n * previous link and the mirror off. `null` for a row the caller has just\n * created with no company on it.\n */\n contact: Record<string, unknown> | null | undefined\n companiesRef: FirebaseFirestore.CollectionReference\n groupId: string\n /** The company to link for this holder, or `null` to unlink. */\n companyId: string | null\n}\n\n/**\n * Link a contact to a company FOR ONE HOLDER — or unlink, with `null` — as\n * ONE commit: the facet and the mirror on the contact, and the count on\n * each company the link moves. `null` when the document already said so.\n *\n * A batch rather than two writes because the count is a derived figure of\n * the mirror: a mirror that changed while the count did not is a company\n * list that says \"3\" over a page of four. `updatedAt` moves on the contact\n * — a link is an edit of the person's record — and not on the company,\n * whose list is ordered by its own edits and would otherwise reshuffle on\n * every capture.\n */\nexport async function writeContactCompanyLink(\n options: WriteContactCompanyLinkOptions,\n): Promise<ContactCompanyLinkPlan | null> {\n const { firestore, contactRef, contact, companiesRef, groupId, companyId } =\n options\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n companyId,\n )\n if (!plan) return null\n const batch = firestore.batch()\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n for (const count of plan.counts) {\n batch.update(\n companiesRef.doc(count.companyId),\n companyContactsCountFields(count.delta),\n )\n }\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n return plan\n}\n"],"names":["COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","planContactCompanyLink","readContactCompanyLink","restampCrmListFieldsAt","contactFacetPath","FieldValue","contactCompanyMirrorValue","plan","change","mirror","undefined","op","arrayUnion","companyId","arrayRemove","companyIds","contactCompanyLinkFields","groupId","delete","companyContactsCountFields","delta","increment","settleCompanyContactsCounts","companiesRef","counts","length","Promise","all","map","count","doc","update","catch","error","console","writeContactCompanyLink","options","firestore","contactRef","contact","batch","updatedAt","serverTimestamp","commit"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;CAcC,GAED,SACEA,4BAA4B,EAC5BC,yBAAyB,EAEzBC,sBAAsB,EACtBC,sBAAsB,QACjB,6BAA4B;AACnC,SAASC,sBAAsB,QAAQ,mBAAe;AACtD,SAASC,gBAAgB,QAAQ,kCAAiC;AAClE,SAASC,UAAU,QAAQ,2BAA0B;AAErD;;;;CAIC,GACD,OAAO,SAASC,0BACdC,IAA4B;IAE5B,MAAMC,SAASD,KAAKE,MAAM;IAC1B,IAAI,CAACD,QAAQ,OAAOE;IACpB,IAAIF,OAAOG,EAAE,KAAK,SAAS,OAAON,WAAWO,UAAU,CAACJ,OAAOK,SAAS;IACxE,IAAIL,OAAOG,EAAE,KAAK,UAAU,OAAON,WAAWS,WAAW,CAACN,OAAOK,SAAS;IAC1E,OAAOL,OAAOO,UAAU;AAC1B;AAEA;;;;CAIC,GACD,OAAO,SAASC,yBACdT,IAA4B,EAC5BU,OAAe;QAI6BV;IAF5C,MAAME,SAASH,0BAA0BC;IACzC,OAAO;QACL,CAACH,iBAAiBa,SAAS,aAAa,GAAEV,kBAAAA,KAAKM,SAAS,YAAdN,kBAAkBF,WAAWa,MAAM;OACzET,WAAWC,YAAY;QAAE,CAACV,0BAA0B,EAAES;IAAO,IAAI,CAAC;AAE1E;AAEA,8DAA8D,GAC9D,OAAO,SAASU,2BAA2BC,KAAa;IACtD,OAAO;QAAE,CAACrB,6BAA6B,EAAEM,WAAWgB,SAAS,CAACD;IAAO;AACvE;AAEA;;;;;;;;;;CAUC,GACD,OAAO,eAAeE,4BACpBC,YAA0D,EAC1DhB,IAAmC;IAEnC,IAAI,CAACgB,gBAAgB,EAAChB,wBAAAA,KAAMiB,MAAM,CAACC,MAAM,GAAE;IAC3C,MAAMC,QAAQC,GAAG,CACfpB,KAAKiB,MAAM,CAACI,GAAG,CAAC,CAACC,QACfN,aACGO,GAAG,CAACD,MAAMhB,SAAS,EACnBkB,MAAM,CAACZ,2BAA2BU,MAAMT,KAAK,GAC7CY,KAAK,CAAC,CAACC;YACNC,QAAQD,KAAK,CACX,wCACAJ,MAAMhB,SAAS,EACfgB,MAAMT,KAAK,EACXa;QAEJ;AAGR;AAiBA;;;;;;;;;;;CAWC,GACD,OAAO,eAAeE,wBACpBC,OAAuC;IAEvC,MAAM,EAAEC,SAAS,EAAEC,UAAU,EAAEC,OAAO,EAAEhB,YAAY,EAAEN,OAAO,EAAEJ,SAAS,EAAE,GACxEuB;IACF,MAAM7B,OAAON,uBACXC,uBAAuBqC,SAAStB,UAChCJ;IAEF,IAAI,CAACN,MAAM,OAAO;IAClB,MAAMiC,QAAQH,UAAUG,KAAK;IAC7BA,MAAMT,MAAM,CAACO,YAAY,aACpBtB,yBAAyBT,MAAMU;QAClCwB,WAAWpC,WAAWqC,eAAe;;IAEvC,KAAK,MAAMb,SAAStB,KAAKiB,MAAM,CAAE;QAC/BgB,MAAMT,MAAM,CACVR,aAAaO,GAAG,CAACD,MAAMhB,SAAS,GAChCM,2BAA2BU,MAAMT,KAAK;IAE1C;IACA,MAAMoB,MAAMG,MAAM;IAClB,+DAA+D;IAC/D,MAAMxC,uBAAuBmC,YAAY;IACzC,OAAO/B;AACT"}
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
* hears one that did not happen.
|
|
42
42
|
*/ import { consentGroupForHost, advanceContactLifecycleStage, contactFacetPath, isContactLifecycleStage, readContactFacet } from "@aglyn/aglyn/server";
|
|
43
43
|
import { FieldValue } from "firebase-admin/firestore";
|
|
44
|
+
import { restampCrmListFieldsAt } from "./crm-records.js";
|
|
44
45
|
export async function floorContactLifecycleStage(options) {
|
|
45
46
|
var _snapshot_data, _options_hostId, _data_hostId, _advanceContactLifecycleStage, _data_email;
|
|
46
47
|
const { contactRef, org, floor } = options;
|
|
@@ -74,6 +75,8 @@ export async function floorContactLifecycleStage(options) {
|
|
|
74
75
|
[contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,
|
|
75
76
|
updatedAt: FieldValue.serverTimestamp()
|
|
76
77
|
});
|
|
78
|
+
// The stage is what the Contacts list filters by (AGL-3321).
|
|
79
|
+
await restampCrmListFieldsAt(snapshot.ref, 'contacts');
|
|
77
80
|
return {
|
|
78
81
|
outcome: 'advanced',
|
|
79
82
|
contactId: snapshot.id,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-lifecycle-floor.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A person's lifecycle stage, FLOORED at what just happened to them\n * (AGL-2641).\n *\n * The capture doors floor a stage inside `upsertHostContact`: an order\n * names `customer` as its `initialLifecycleStage`, and the door fills an\n * empty stage or advances an earlier one and never moves anybody back. A\n * won deal is the same fact as a purchase — the business has decided this\n * person bought — but it arrives at a record that already exists, through a\n * writer that is not a capture door: the deal-stage route and the REST\n * deals resource. This is the door's rule for them, over a contact document\n * they already hold a reference to.\n *\n * The write goes into ONE holder's facet, because a stage is one business's\n * reading of a person and the row is shared by every site in the org. The\n * holder is the site the deal was made on, resolved to its consent group\n * the way every other facet writer resolves it; a deal that names no site\n * falls back to the site that captured the person, which is the one\n * holder a contact always has. A contact that names none either is left\n * alone and the caller told, rather than written into a facet nobody reads.\n *\n * The write is skipped when it would change nothing — a customer stays a\n * customer, an evangelist stays an evangelist, `other` is never overwritten\n * — so a caller can announce `contactStageChanged` exactly when `advanced`\n * says the stage moved, and an automation listening for the change never\n * hears one that did not happen.\n */\n\nimport {\n consentGroupForHost,\n advanceContactLifecycleStage,\n contactFacetPath,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\n\nexport type ContactLifecycleFloor =\n | {\n /** The stage was raised to the floor. */\n outcome: 'advanced'\n contactId: string\n /** The address on the row, for the event a caller announces. */\n email: string\n /** The site whose facet was written. */\n hostId: string\n groupId: string\n /** The stage before the write, or `''` for a person who had none. */\n previousStage: ContactLifecycleStage | ''\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** The person already held the floor or a later stage; nothing was written. */\n outcome: 'held'\n contactId: string\n email: string\n hostId: string\n groupId: string\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** No such contact. */\n outcome: 'missing'\n contactId: string\n }\n | {\n /** Neither the caller nor the contact names a site, so there is no facet to write. */\n outcome: 'unheld'\n contactId: string\n }\n\nexport interface ContactLifecycleFloorOptions {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The org the contact belongs to, for the consent groups — the same\n * document every facet writer resolves the holder through.\n */\n org: Record<string, unknown> | null | undefined\n /**\n * The site the act happened on, whose facet takes the stage. `null` for a\n * record no site captured; the contact's own capturing site is used then.\n */\n hostId: string | null | undefined\n /** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */\n floor: ContactLifecycleStage\n}\n\nexport async function floorContactLifecycleStage(\n options: ContactLifecycleFloorOptions,\n): Promise<ContactLifecycleFloor> {\n const { contactRef, org, floor } = options\n const snapshot = await contactRef.get()\n if (!snapshot.exists) return { outcome: 'missing', contactId: contactRef.id }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const hostId = String(options.hostId ?? '').trim() || String(data['hostId'] ?? '').trim()\n if (!hostId) return { outcome: 'unheld', contactId: snapshot.id }\n\n const { groupId } = consentGroupForHost(org ?? null, hostId)\n const facet = readContactFacet(data, groupId)\n const held = isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : undefined\n const lifecycleStage = advanceContactLifecycleStage(held, floor) ?? floor\n const email = String(data['email'] ?? '')\n if (lifecycleStage === held) {\n return { outcome: 'held', contactId: snapshot.id, email, hostId, groupId, lifecycleStage }\n }\n await snapshot.ref.update({\n [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n return {\n outcome: 'advanced',\n contactId: snapshot.id,\n email,\n hostId,\n groupId,\n previousStage: held ?? '',\n lifecycleStage,\n }\n}\n"],"names":["consentGroupForHost","advanceContactLifecycleStage","contactFacetPath","isContactLifecycleStage","readContactFacet","FieldValue","floorContactLifecycleStage","options","snapshot","data","contactRef","org","floor","get","exists","outcome","contactId","id","hostId","String","trim","groupId","facet","held","lifecycleStage","undefined","email","ref","update","updatedAt","serverTimestamp","previousStage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAEhBC,uBAAuB,EACvBC,gBAAgB,QACX,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;
|
|
1
|
+
{"version":3,"sources":["../../../../../../../../libs/tenant/data/admin/src/lib/server/contact-lifecycle-floor.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A person's lifecycle stage, FLOORED at what just happened to them\n * (AGL-2641).\n *\n * The capture doors floor a stage inside `upsertHostContact`: an order\n * names `customer` as its `initialLifecycleStage`, and the door fills an\n * empty stage or advances an earlier one and never moves anybody back. A\n * won deal is the same fact as a purchase — the business has decided this\n * person bought — but it arrives at a record that already exists, through a\n * writer that is not a capture door: the deal-stage route and the REST\n * deals resource. This is the door's rule for them, over a contact document\n * they already hold a reference to.\n *\n * The write goes into ONE holder's facet, because a stage is one business's\n * reading of a person and the row is shared by every site in the org. The\n * holder is the site the deal was made on, resolved to its consent group\n * the way every other facet writer resolves it; a deal that names no site\n * falls back to the site that captured the person, which is the one\n * holder a contact always has. A contact that names none either is left\n * alone and the caller told, rather than written into a facet nobody reads.\n *\n * The write is skipped when it would change nothing — a customer stays a\n * customer, an evangelist stays an evangelist, `other` is never overwritten\n * — so a caller can announce `contactStageChanged` exactly when `advanced`\n * says the stage moved, and an automation listening for the change never\n * hears one that did not happen.\n */\n\nimport {\n consentGroupForHost,\n advanceContactLifecycleStage,\n contactFacetPath,\n type ContactLifecycleStage,\n isContactLifecycleStage,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { restampCrmListFieldsAt } from './crm-records'\n\nexport type ContactLifecycleFloor =\n | {\n /** The stage was raised to the floor. */\n outcome: 'advanced'\n contactId: string\n /** The address on the row, for the event a caller announces. */\n email: string\n /** The site whose facet was written. */\n hostId: string\n groupId: string\n /** The stage before the write, or `''` for a person who had none. */\n previousStage: ContactLifecycleStage | ''\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** The person already held the floor or a later stage; nothing was written. */\n outcome: 'held'\n contactId: string\n email: string\n hostId: string\n groupId: string\n lifecycleStage: ContactLifecycleStage\n }\n | {\n /** No such contact. */\n outcome: 'missing'\n contactId: string\n }\n | {\n /** Neither the caller nor the contact names a site, so there is no facet to write. */\n outcome: 'unheld'\n contactId: string\n }\n\nexport interface ContactLifecycleFloorOptions {\n contactRef: FirebaseFirestore.DocumentReference\n /**\n * The org the contact belongs to, for the consent groups — the same\n * document every facet writer resolves the holder through.\n */\n org: Record<string, unknown> | null | undefined\n /**\n * The site the act happened on, whose facet takes the stage. `null` for a\n * record no site captured; the contact's own capturing site is used then.\n */\n hostId: string | null | undefined\n /** The EARLIEST stage that describes what happened — see `advanceContactLifecycleStage`. */\n floor: ContactLifecycleStage\n}\n\nexport async function floorContactLifecycleStage(\n options: ContactLifecycleFloorOptions,\n): Promise<ContactLifecycleFloor> {\n const { contactRef, org, floor } = options\n const snapshot = await contactRef.get()\n if (!snapshot.exists) return { outcome: 'missing', contactId: contactRef.id }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const hostId = String(options.hostId ?? '').trim() || String(data['hostId'] ?? '').trim()\n if (!hostId) return { outcome: 'unheld', contactId: snapshot.id }\n\n const { groupId } = consentGroupForHost(org ?? null, hostId)\n const facet = readContactFacet(data, groupId)\n const held = isContactLifecycleStage(facet.lifecycleStage)\n ? facet.lifecycleStage\n : undefined\n const lifecycleStage = advanceContactLifecycleStage(held, floor) ?? floor\n const email = String(data['email'] ?? '')\n if (lifecycleStage === held) {\n return { outcome: 'held', contactId: snapshot.id, email, hostId, groupId, lifecycleStage }\n }\n await snapshot.ref.update({\n [contactFacetPath(groupId, 'lifecycleStage')]: lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\n return {\n outcome: 'advanced',\n contactId: snapshot.id,\n email,\n hostId,\n groupId,\n previousStage: held ?? '',\n lifecycleStage,\n }\n}\n"],"names":["consentGroupForHost","advanceContactLifecycleStage","contactFacetPath","isContactLifecycleStage","readContactFacet","FieldValue","restampCrmListFieldsAt","floorContactLifecycleStage","options","snapshot","data","contactRef","org","floor","get","exists","outcome","contactId","id","hostId","String","trim","groupId","facet","held","lifecycleStage","undefined","email","ref","update","updatedAt","serverTimestamp","previousStage"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,4BAA4B,EAC5BC,gBAAgB,EAEhBC,uBAAuB,EACvBC,gBAAgB,QACX,sBAAqB;AAC5B,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,sBAAsB,QAAQ,mBAAe;AAoDtD,OAAO,eAAeC,2BACpBC,OAAqC;QAKvBC,gBACQD,iBAAuCE,cAQtCT,+BACFS;IAbrB,MAAM,EAAEC,UAAU,EAAEC,GAAG,EAAEC,KAAK,EAAE,GAAGL;IACnC,MAAMC,WAAW,MAAME,WAAWG,GAAG;IACrC,IAAI,CAACL,SAASM,MAAM,EAAE,OAAO;QAAEC,SAAS;QAAWC,WAAWN,WAAWO,EAAE;IAAC;IAC5E,MAAMR,QAAQD,iBAAAA,SAASC,IAAI,cAAbD,iBAAmB,CAAC;IAClC,MAAMU,SAASC,QAAOZ,kBAAAA,QAAQW,MAAM,YAAdX,kBAAkB,IAAIa,IAAI,MAAMD,QAAOV,eAAAA,IAAI,CAAC,SAAS,YAAdA,eAAkB,IAAIW,IAAI;IACvF,IAAI,CAACF,QAAQ,OAAO;QAAEH,SAAS;QAAUC,WAAWR,SAASS,EAAE;IAAC;IAEhE,MAAM,EAAEI,OAAO,EAAE,GAAGtB,oBAAoBY,cAAAA,MAAO,MAAMO;IACrD,MAAMI,QAAQnB,iBAAiBM,MAAMY;IACrC,MAAME,OAAOrB,wBAAwBoB,MAAME,cAAc,IACrDF,MAAME,cAAc,GACpBC;IACJ,MAAMD,kBAAiBxB,gCAAAA,6BAA6BuB,MAAMX,kBAAnCZ,gCAA6CY;IACpE,MAAMc,QAAQP,QAAOV,cAAAA,IAAI,CAAC,QAAQ,YAAbA,cAAiB;IACtC,IAAIe,mBAAmBD,MAAM;QAC3B,OAAO;YAAER,SAAS;YAAQC,WAAWR,SAASS,EAAE;YAAES;YAAOR;YAAQG;YAASG;QAAe;IAC3F;IACA,MAAMhB,SAASmB,GAAG,CAACC,MAAM,CAAC;QACxB,CAAC3B,iBAAiBoB,SAAS,kBAAkB,EAAEG;QAC/CK,WAAWzB,WAAW0B,eAAe;IACvC;IACA,6DAA6D;IAC7D,MAAMzB,uBAAuBG,SAASmB,GAAG,EAAE;IAC3C,OAAO;QACLZ,SAAS;QACTC,WAAWR,SAASS,EAAE;QACtBS;QACAR;QACAG;QACAU,aAAa,EAAER,eAAAA,OAAQ;QACvBC;IACF;AACF"}
|
|
@@ -54,6 +54,7 @@ import { personKey } from "@aglyn/aglyn/app-utils/person-key";
|
|
|
54
54
|
import { FieldValue } from "firebase-admin/firestore";
|
|
55
55
|
import { settleCompanyContactsCounts } from "./contact-company-link.js";
|
|
56
56
|
import { recomputeCrmNextTaskAt } from "./crm-next-activity.js";
|
|
57
|
+
import { restampCrmListFieldsAt } from "./crm-records.js";
|
|
57
58
|
import { logHostActivity } from "./organizations.js";
|
|
58
59
|
/** How many pointing rows one repoint pass reads — and one batch writes. */ const REPOINT_PAGE = 400;
|
|
59
60
|
/** A ceiling on passes, so a query that keeps answering cannot loop forever. */ const REPOINT_PASSES = 25;
|
|
@@ -203,6 +204,9 @@ export async function mergeContacts(options) {
|
|
|
203
204
|
reason: swapped.reason
|
|
204
205
|
};
|
|
205
206
|
const { plan } = swapped;
|
|
207
|
+
// The survivor now carries both records' addresses, holders and facets —
|
|
208
|
+
// what the Contacts list searches and filters by (AGL-3321).
|
|
209
|
+
await restampCrmListFieldsAt(survivorRef, 'contacts');
|
|
206
210
|
/*
|
|
207
211
|
* The companies both records named counted two people and now count one
|
|
208
212
|
* (AGL-2613). Settled after the swap the way the capture settles its
|
|
@@ -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
|