@aglyn/plugins-crm 1.0.0-beta.196 → 1.0.0-beta.198

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.
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/consent-group-participant.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 CRM'S SHARE OF A CONSENT GROUP CHANGE (AGL-3320).\n *\n * Two things the CRM keeps are read through the CURRENT consent group, so a\n * change to the declaration moves under them unless they move too:\n *\n * - **C4 — a contact's per-site refusal.** `marketingConsentByHost.{site}`\n * holding `marketingConsent: false` refuses mail from every site of that\n * site's group (`readMarketingBasis`). When two sites stop being one\n * sender, each keeps the refusals the other held: the refusal is written\n * onto the receiving site's entry by dotted path, with the one it\n * replaced kept as `supersededEntry`. A refusal is carried whenever it was\n * given — over-carrying is the safe direction — and one the receiving site\n * already holds is left alone. Only contacts carry per-site refusals;\n * leads and list memberships carry grants alone.\n * - **The holder records**, `facets.{groupId}` — a profile, an owner, a\n * stage, notes, order figures and a timeline, keyed by the group that\n * keeps them. Every writer keys by the declaration in force, so after the\n * flip a site's records must be under its new key:\n * - a MOVE folds every old key's facet into the new key's, with the\n * existing target first and then the sources in the order the person\n * met their sites, through `mergeContactFacet` — the survivor's\n * values win, lists union, figures sum — and deletes each source in\n * the same write, so a figure is summed exactly once;\n * - a SPLIT copies the facet, without its figures and with only the\n * timeline entries of the successor's own sites, to each successor\n * that met the person itself, merged under whatever the successor\n * already holds. The figures follow the capture only when every site\n * that met the person lands under one key; otherwise they stay with the\n * surviving key, or — when the key dissolves — are dropped and counted.\n *\n * ## Run twice, write once\n *\n * The executor runs `carry` before the flip and again as the catch-up, and\n * the sweep runs everything once more. A carried refusal is skipped once the\n * receiving entry refuses; a MOVE or a dissolving SPLIT deletes its source in\n * the write that folds it, so a second pass finds nothing — except a\n * straggler a long job wrote under the old key after the first pass, which is\n * exactly what the sweep exists to fold in. A SPLIT whose key survives stays\n * a live key, so each copy is stamped with the change that made it\n * (`copiedByChange`), and a pass that finds the stamp does not copy again.\n */\n\nimport { mergeContactFacet } from '@aglyn/aglyn/app-utils/contact-merge'\nimport type {\n ConsentGroupChangePlan,\n ConsentGroupChangePreviewLine,\n ConsentGroupHolderMove,\n ConsentGroupHolderSplit,\n} from '@aglyn/aglyn/app-utils/consent-group-change'\nimport {\n CONTACT_FACETS_FIELD,\n type ContactInteraction,\n interactionsForGroup,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n CONTACT_COMPANY_IDS_FIELD,\n CRM_COLLECTIONS,\n} from '@aglyn/aglyn/app-utils/crm'\nimport {\n CAPTURED_BY_HOST_FIELD,\n CONSENT_GROUP_ID_FIELD,\n MARKETING_CONSENT_BY_HOST_FIELD,\n MARKETING_CONSENT_FIELD,\n MARKETING_CONSENT_SOURCE_FIELD,\n} from '@aglyn/aglyn/app-utils/marketing-consent'\nimport type {\n ConsentGroupChangeParticipant,\n ConsentGroupChangeRunRequest,\n ConsentGroupChangeRunResult,\n} from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { summarizeConsentGroupChange } from '../model/consent-group-summary'\n\n/** Contacts one page of the refusal carry reads. */\nexport const REFUSAL_PAGE = 300\n\n/** Contacts one page of the holder pass reads. */\nexport const HOLDER_PAGE = 200\n\n/**\n * The most records a preview reads to answer a count no aggregation can:\n * past it, the line says so with a `null` count rather than guess.\n */\nexport const PREVIEW_SCAN_LIMIT = 5_000\n\n/**\n * The figures a facet sums, and the instants that go with them — what\n * `mergeContactFacet` adds up or takes the earliest or latest of. A split\n * cannot divide one person's purchases between two holders, so a copy\n * leaves them behind.\n */\nexport const HOLDER_FIGURE_FIELDS = [\n 'ltvCents',\n 'ordersCount',\n 'refundedCents',\n 'refundedOrdersCount',\n 'firstPurchaseAtMs',\n 'lastPurchaseAtMs',\n 'lastRefundAtMs',\n 'lastEmailEngagementAtMs',\n] as const\n\n/** The stamp a split's copy carries: `{ [sourceKey]: changeId }`. */\nexport const COPIED_BY_CHANGE_FIELD = 'copiedByChange'\n\ntype Doc = Record<string, unknown>\n\n/** The error codes that mean the contact was written since it was read. */\nconst RACED = new Set([5, 9])\nconst codeOf = (error: unknown): number | undefined => (error as { code?: number } | null)?.code\n\nfunction mapOf(value: unknown): Doc {\n return value && typeof value === 'object' && !Array.isArray(value) ? (value as Doc) : {}\n}\n\n/** The sites that met this person, in the order they did — then the legacy `hostId`. */\nfunction captureOrder(contact: Doc): string[] {\n const raw = contact[CAPTURED_BY_HOST_FIELD]\n const ordered = Array.isArray(raw)\n ? raw.map((id) => String(id ?? '').trim()).filter(Boolean)\n : []\n const legacy = typeof contact['hostId'] === 'string' ? (contact['hostId'] as string) : ''\n return legacy && !ordered.includes(legacy) ? [...ordered, legacy] : ordered\n}\n\nfunction withoutFigures(facet: Doc): Doc {\n const out: Doc = { ...facet }\n for (const field of HOLDER_FIGURE_FIELDS) delete out[field]\n return out\n}\n\nfunction figuresOf(facet: Doc): Doc {\n const out: Doc = {}\n for (const field of HOLDER_FIGURE_FIELDS) {\n if (field in facet) out[field] = facet[field]\n }\n return out\n}\n\n/** What one contact's holder records become, as a dotted `update()` patch. */\nexport interface HolderPatch {\n patch: Doc\n combined: number\n copied: number\n figuresDropped: number\n /** Each company whose contacts count moves, by how much. */\n companyCounts: Array<{ companyId: string; delta: 1 | -1 }>\n}\n\n/**\n * The holder records of one contact after the plan's flows — or `null` when\n * nothing moves.\n *\n * `stragglersOnly` is the sweep: only the flows that delete their source\n * (MOVE, and a SPLIT whose key dissolves) run, because only those can have\n * left anything behind that a later writer recreated. A key that survived a\n * split is a live key after the flip, and its new writes belong to it.\n */\nexport function planHolderRecords(\n contact: Doc,\n plan: Pick<ConsentGroupChangePlan, 'flows'>,\n changeId: string,\n options: { stragglersOnly?: boolean } = {},\n): HolderPatch | null {\n const facets = mapOf(contact[CONTACT_FACETS_FIELD]) as Record<string, Doc>\n const captured = captureOrder(contact)\n const next: Record<string, Doc | null> = {}\n const current = (key: string): Doc | undefined =>\n key in next ? (next[key] ?? undefined) : facets[key]\n let combined = 0\n let copied = 0\n let figuresDropped = 0\n\n const splits = plan.flows.filter(\n (flow): flow is ConsentGroupHolderSplit =>\n flow.kind === 'split' && !(options.stragglersOnly && flow.survives),\n )\n const copies: Array<{ key: string; facet: Doc }> = []\n for (const split of splits) {\n const source = facets[split.from]\n if (!source || typeof source !== 'object') continue\n if (\n split.survives &&\n split.successors.some(\n (successor) => mapOf(facets[successor.key]?.[COPIED_BY_CHANGE_FIELD])[split.from] === changeId,\n )\n ) {\n continue\n }\n const keyOf = (hostId: string) =>\n split.stayHostIds.includes(hostId)\n ? split.from\n : (split.successors.find((successor) => successor.hostIds.includes(hostId))?.key ?? null)\n let eligible = split.successors.filter((successor) =>\n successor.hostIds.some((hostId) => captured.includes(hostId)),\n )\n if (!split.survives && !eligible.length) eligible = split.successors\n const landing = [\n ...new Set(\n captured\n .filter((hostId) => split.fromHostIds.includes(hostId))\n .map(keyOf)\n .filter((key): key is string => key !== null),\n ),\n ]\n const figuresTo =\n landing.length === 1 && landing[0] !== split.from && eligible.some((s) => s.key === landing[0])\n ? landing[0]\n : null\n for (const successor of eligible) {\n const copy: Doc = {\n ...withoutFigures(source),\n interactions: interactionsForGroup(\n (source['interactions'] ?? []) as ContactInteraction[],\n successor.hostIds,\n ),\n ...(successor.key === figuresTo ? figuresOf(source) : {}),\n }\n if (split.survives) copy[COPIED_BY_CHANGE_FIELD] = { [split.from]: changeId }\n copies.push({ key: successor.key, facet: copy })\n }\n if (split.survives) {\n if (figuresTo) next[split.from] = withoutFigures(current(split.from) ?? source)\n } else {\n next[split.from] = null\n const figures = figuresOf(source)\n if (!figuresTo && Object.keys(figures).some((field) => field !== 'lastEmailEngagementAtMs')) {\n figuresDropped += 1\n }\n }\n }\n\n const moves = new Map<string, ConsentGroupHolderMove[]>()\n for (const flow of plan.flows) {\n if (flow.kind !== 'move' || !facets[flow.from]) continue\n moves.set(flow.to, [...(moves.get(flow.to) ?? []), flow])\n }\n const metAt = (hostIds: readonly string[]) => {\n const indexes = hostIds.map((hostId) => captured.indexOf(hostId)).filter((index) => index >= 0)\n return indexes.length ? Math.min(...indexes) : Number.POSITIVE_INFINITY\n }\n for (const [target, sources] of moves) {\n const ordered = [...sources].sort(\n (a, b) => metAt(a.fromHostIds) - metAt(b.fromHostIds) || (a.from < b.from ? -1 : a.from > b.from ? 1 : 0),\n )\n let folded = current(target)\n // A person is COMBINED when two or more records become one; a lone\n // record under a new key is only moved.\n if (ordered.length + (folded ? 1 : 0) >= 2) combined += 1\n for (const source of ordered) {\n const facet = facets[source.from]\n folded = folded ? mergeContactFacet(folded, facet) : { ...facet }\n next[source.from] = null\n }\n if (folded) next[target] = folded\n }\n\n for (const { key, facet } of copies) {\n const existing = current(key)\n const merged = existing ? mergeContactFacet(existing, facet) : facet\n // The stamps are a union, never the survivor's alone: the merge keeps a\n // map the survivor already has, and a lost stamp would copy again.\n const stamps = {\n ...mapOf(existing?.[COPIED_BY_CHANGE_FIELD]),\n ...mapOf(facet[COPIED_BY_CHANGE_FIELD]),\n }\n if (Object.keys(stamps).length) merged[COPIED_BY_CHANGE_FIELD] = stamps\n next[key] = merged\n copied += 1\n }\n\n const keys = Object.keys(next)\n if (!keys.length) return null\n\n /*\n * The company mirror follows the facets: an id that no facet names any\n * more leaves it, and one a facet newly names joins it. Ids the mirror held\n * without a facet naming them — links older than the facets — are left.\n */\n const named = (source: Record<string, Doc | null | undefined>) =>\n new Set(\n Object.values(source)\n .map((facet) => (facet ? facet['companyId'] : null))\n .filter((id): id is string => typeof id === 'string' && id !== ''),\n )\n const after: Record<string, Doc | null> = { ...facets, ...next }\n const before = named(facets)\n const now = named(after)\n const mirror = Array.isArray(contact[CONTACT_COMPANY_IDS_FIELD])\n ? (contact[CONTACT_COMPANY_IDS_FIELD] as unknown[]).filter(\n (id): id is string => typeof id === 'string' && id !== '',\n )\n : []\n const companyCounts: HolderPatch['companyCounts'] = []\n const leaving = [...before].filter((id) => !now.has(id))\n const joining = [...now].filter((id) => !before.has(id))\n for (const id of leaving) if (mirror.includes(id)) companyCounts.push({ companyId: id, delta: -1 })\n for (const id of joining) if (!mirror.includes(id)) companyCounts.push({ companyId: id, delta: 1 })\n\n const patch: Doc = {}\n for (const key of keys) {\n patch[`${CONTACT_FACETS_FIELD}.${key}`] = next[key] === null ? FieldValue.delete() : next[key]\n }\n if (companyCounts.length) {\n patch[CONTACT_COMPANY_IDS_FIELD] = [\n ...mirror.filter((id) => !leaving.includes(id)),\n ...joining.filter((id) => !mirror.includes(id)),\n ]\n }\n patch['updatedAt'] = FieldValue.serverTimestamp()\n return { patch, combined, copied, figuresDropped, companyCounts }\n}\n\n/**\n * The refusal `from` holds, carried onto `to` — or `null` when `to` already\n * refuses or `from` does not.\n */\nexport function planRefusalCarry(\n contact: Doc,\n carry: { toHostId: string; fromHostId: string },\n changeId: string,\n): Doc | null {\n const byHost = mapOf(contact[MARKETING_CONSENT_BY_HOST_FIELD])\n const source = mapOf(byHost[carry.fromHostId])\n if (source[MARKETING_CONSENT_FIELD] !== false) return null\n const target = byHost[carry.toHostId]\n if (mapOf(target)[MARKETING_CONSENT_FIELD] === false) return null\n const entry: Doc = {\n [MARKETING_CONSENT_FIELD]: false,\n ...(source['marketingConsentAtMs'] !== undefined\n ? { marketingConsentAtMs: source['marketingConsentAtMs'] }\n : {}),\n ...(source[MARKETING_CONSENT_SOURCE_FIELD] !== undefined\n ? { [MARKETING_CONSENT_SOURCE_FIELD]: source[MARKETING_CONSENT_SOURCE_FIELD] }\n : {}),\n carriedFromHostId: carry.fromHostId,\n carriedByChangeId: changeId,\n ...(target && typeof target === 'object' ? { supersededEntry: target } : {}),\n }\n return {\n [`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.toHostId}`]: entry,\n updatedAt: FieldValue.serverTimestamp(),\n }\n}\n\n/*==========================================\n * THE PASSES\n *=========================================*/\n\ninterface Cursor {\n /** `refusals` walks the carries; `holders` walks every contact. */\n stage: 'refusals' | 'holders'\n /** The carry being walked, in `refusals`. */\n carry: number\n after: string | null\n}\n\nfunction readCursor(raw: string | null, first: Cursor['stage']): Cursor {\n if (raw) {\n try {\n const parsed = JSON.parse(raw) as Cursor\n if (parsed && (parsed.stage === 'refusals' || parsed.stage === 'holders')) return parsed\n } catch {\n // A cursor this participant did not write starts the phase over, which\n // every pass is safe to do.\n }\n }\n return { stage: first, carry: 0, after: null }\n}\n\nexport interface ConsentGroupParticipantOptions {\n firestore?: () => FirebaseFirestore.Firestore\n now?: () => number\n}\n\n/** One contact write, guarded by the read it was computed from. */\ninterface Pending {\n snapshot: FirebaseFirestore.QueryDocumentSnapshot | FirebaseFirestore.DocumentSnapshot\n patch: Doc\n after?: () => Promise<void>\n}\n\n/**\n * Writes a page's patches, each with a `lastUpdateTime` precondition, and\n * recomputes the ones that raced once from a fresh read. Answers how many\n * were written.\n */\nasync function writeGuarded(\n firestore: FirebaseFirestore.Firestore,\n pending: Pending[],\n recompute: (snapshot: FirebaseFirestore.DocumentSnapshot) => Pending | null,\n): Promise<number> {\n let written = 0\n let due = pending\n for (let round = 0; round < 2 && due.length; round += 1) {\n const writer = firestore.bulkWriter()\n const outcomes = due.map((entry) =>\n writer\n .update(entry.snapshot.ref, entry.patch, {\n lastUpdateTime: entry.snapshot.updateTime as FirebaseFirestore.Timestamp,\n })\n .then(\n () => ({ entry, error: null as unknown }),\n (error: unknown) => ({ entry, error: error ?? new Error('The write failed') }),\n ),\n )\n await writer.close()\n const raced: Pending[] = []\n for (const { entry, error } of await Promise.all(outcomes)) {\n if (error === null) {\n written += 1\n await entry.after?.()\n continue\n }\n const code = codeOf(error)\n if (code === undefined || !RACED.has(code)) throw error\n raced.push(entry)\n }\n if (!raced.length) return written\n const fresh = await firestore.getAll(...raced.map((entry) => entry.snapshot.ref))\n due = fresh\n .filter((snapshot) => snapshot.exists)\n .map(recompute)\n .filter((entry): entry is Pending => entry !== null)\n }\n if (due.length) {\n throw new Error(`[crm] ${due.length} contact(s) kept changing under the consent group change`)\n }\n return written\n}\n\n/**\n * The CRM's participant. `firestore` and `now` are injectable for the specs;\n * the defaults are the Admin SDK's and the wall clock.\n */\nexport function createConsentGroupParticipant(\n options: ConsentGroupParticipantOptions = {},\n): ConsentGroupChangeParticipant {\n const db = () => (options.firestore ?? (() => firebaseAdmin.app().firestore()))()\n const now = options.now ?? Date.now\n const contactsOf = (orgId: string) => db().collection('orgs').doc(orgId).collection('contacts')\n\n async function countOf(query: FirebaseFirestore.Query): Promise<number> {\n return Number((await query.count().get()).data().count) || 0\n }\n\n /** One page of one carry's refusals. */\n async function refusalPage(\n request: ConsentGroupChangeRunRequest,\n cursor: Cursor,\n counts: Record<string, number>,\n ): Promise<Cursor | null> {\n const carries = request.plan.carries\n if (cursor.carry >= carries.length) return null\n const carry = carries[cursor.carry]\n let query = contactsOf(request.orgId)\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${carry.fromHostId}.${MARKETING_CONSENT_FIELD}`, '==', false)\n .orderBy(FieldPath.documentId())\n .limit(REFUSAL_PAGE)\n if (cursor.after) query = query.startAfter(cursor.after)\n const page = await query.get()\n const plan = (snapshot: FirebaseFirestore.DocumentSnapshot): Pending | null => {\n const patch = planRefusalCarry((snapshot.data() ?? {}) as Doc, carry, request.changeId)\n return patch ? { snapshot, patch } : null\n }\n const pending = page.docs.map(plan).filter((entry): entry is Pending => entry !== null)\n counts['refusals'] = (counts['refusals'] ?? 0) +\n (request.dryRun ? pending.length : await writeGuarded(db(), pending, plan))\n if (page.size < REFUSAL_PAGE) return { stage: 'refusals', carry: cursor.carry + 1, after: null }\n return { stage: 'refusals', carry: cursor.carry, after: page.docs[page.docs.length - 1].id }\n }\n\n /** One page of the holder pass over every contact. */\n async function holderPage(\n request: ConsentGroupChangeRunRequest,\n cursor: Cursor,\n counts: Record<string, number>,\n ): Promise<Cursor | null> {\n let query = contactsOf(request.orgId).orderBy(FieldPath.documentId()).limit(HOLDER_PAGE)\n if (cursor.after) query = query.startAfter(cursor.after)\n const page = await query.get()\n const companies = db().collection('orgs').doc(request.orgId).collection(CRM_COLLECTIONS.companies)\n const stragglersOnly = request.phase === 'sweep'\n const tally = { combined: 0, copied: 0, figuresDropped: 0 }\n const count = (planned: HolderPatch) => {\n tally.combined += planned.combined\n tally.copied += planned.copied\n tally.figuresDropped += planned.figuresDropped\n }\n const plan = (snapshot: FirebaseFirestore.DocumentSnapshot): (Pending & { planned: HolderPatch }) | null => {\n const planned = planHolderRecords((snapshot.data() ?? {}) as Doc, request.plan, request.changeId, {\n stragglersOnly,\n })\n if (!planned) return null\n return {\n snapshot,\n planned,\n patch: planned.patch,\n after: async () => {\n count(planned)\n // A count that could not move is logged and left: the company's own\n // page takes the live aggregate, which corrects it.\n await Promise.all(\n planned.companyCounts.map((change) =>\n companies\n .doc(change.companyId)\n .update({ [COMPANY_CONTACTS_COUNT_FIELD]: FieldValue.increment(change.delta) })\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', change.companyId, error)\n }),\n ),\n )\n },\n }\n }\n const pending = page.docs\n .map(plan)\n .filter((entry): entry is Pending & { planned: HolderPatch } => entry !== null)\n if (request.dryRun) for (const entry of pending) count(entry.planned)\n else await writeGuarded(db(), pending, plan)\n counts['combined'] = (counts['combined'] ?? 0) + tally.combined\n counts['copied'] = (counts['copied'] ?? 0) + tally.copied\n counts['figuresDropped'] = (counts['figuresDropped'] ?? 0) + tally.figuresDropped\n if (page.size < HOLDER_PAGE) return null\n return { stage: 'holders', carry: 0, after: page.docs[page.docs.length - 1].id }\n }\n\n return {\n async run(request): Promise<ConsentGroupChangeRunResult> {\n const counts: Record<string, number> = {}\n const hasHolderFlows = request.plan.flows.length > 0\n // `carry` walks the refusals; `rehome` the holders; `sweep` both.\n let cursor: Cursor | null = readCursor(\n request.cursor,\n request.phase === 'rehome' ? 'holders' : 'refusals',\n )\n while (cursor && now() < request.deadlineMs) {\n if (cursor.stage === 'refusals') {\n const next: Cursor | null = await refusalPage(request, cursor, counts)\n cursor =\n next ??\n (request.phase === 'sweep' && hasHolderFlows\n ? { stage: 'holders', carry: 0, after: null }\n : null)\n continue\n }\n cursor = hasHolderFlows ? await holderPage(request, cursor, counts) : null\n }\n return { done: cursor === null, cursor: cursor ? JSON.stringify(cursor) : null, counts }\n },\n\n async preview({ orgId, plan }): Promise<ConsentGroupChangePreviewLine[]> {\n const contacts = contactsOf(orgId)\n const lines: ConsentGroupChangePreviewLine[] = []\n const moves = plan.flows.filter((flow): flow is ConsentGroupHolderMove => flow.kind === 'move')\n const splits = plan.flows.filter((flow): flow is ConsentGroupHolderSplit => flow.kind === 'split')\n const people = (count: number | null) => (count === 1 ? 'person' : 'people')\n const figure = (count: number | null) => (count === null ? 'some' : String(count))\n\n /*\n * `crm.combine`: the people whose records under two or more of the\n * folding keys become one. No aggregation can ask \"two or more\", so the\n * records under each source key are read — bounded, and `null` past\n * the bound — and put through the same fold the re-home runs.\n */\n if (moves.length) {\n const seen = new Map<string, Doc>()\n let bounded = true\n for (const move of moves) {\n const page = await contacts\n .where(`${CONTACT_FACETS_FIELD}.${move.from}`, '!=', null)\n .limit(PREVIEW_SCAN_LIMIT + 1)\n .get()\n if (page.size > PREVIEW_SCAN_LIMIT) bounded = false\n for (const doc of page.docs) seen.set(doc.id, (doc.data() ?? {}) as Doc)\n }\n const combine = bounded\n ? [...seen.values()].reduce(\n (total, contact) =>\n total + (planHolderRecords(contact, { flows: moves }, '')?.combined ?? 0),\n 0,\n )\n : null\n lines.push({\n id: 'crm.combine',\n text: `The CRM records these sites keep about the same person will be combined for ${figure(combine)} ${people(combine)}.`,\n count: combine,\n severity: 'info',\n })\n }\n\n if (splits.length) {\n /*\n * `crm.copy`: the people a separating site takes a copy for. When the\n * key survives, those are the people the separating sites met\n * themselves; when it dissolves, everybody it holds, since a record\n * nobody met goes to every successor.\n */\n let copy = 0\n for (const split of splits) {\n if (!split.survives) {\n copy += await countOf(contacts.where(`${CONTACT_FACETS_FIELD}.${split.from}`, '!=', null))\n continue\n }\n const leavers = split.successors.flatMap((successor) => successor.hostIds)\n for (let index = 0; index < leavers.length; index += 30) {\n copy += await countOf(\n contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains-any', leavers.slice(index, index + 30)),\n )\n }\n }\n lines.push({\n id: 'crm.copy',\n text: `The separating sites keep a copy of the CRM record for the ${copy} ${people(copy)} they met themselves.`,\n count: copy,\n severity: 'info',\n })\n\n /*\n * `crm.grants` and `crm.visibility`, per separating site: the people\n * who signed up to the group through it and still hold that grant,\n * and the people it can see without having met them.\n */\n let grants = 0\n let visible = 0\n for (const split of splits) {\n for (const hostId of split.successors.flatMap((successor) => successor.hostIds)) {\n grants += await countOf(\n contacts\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${CONSENT_GROUP_ID_FIELD}`, '==', split.from)\n .where(`${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`, '==', true),\n )\n const seen = await countOf(contacts.where('visibleTo', 'array-contains', `host:${hostId}`))\n const met = await countOf(contacts.where(CAPTURED_BY_HOST_FIELD, 'array-contains', hostId))\n visible += Math.max(0, seen - met)\n }\n }\n lines.push({\n id: 'crm.grants',\n text: `The separating sites can still email the ${grants} ${people(grants)} who signed up to the group while they were part of it.`,\n count: grants,\n severity: 'info',\n })\n lines.push({\n id: 'crm.visibility',\n text: `The separating sites will still see ${visible} ${people(visible)} they met only through the group's other sites, without their CRM details.`,\n count: visible,\n severity: 'info',\n })\n\n /*\n * `crm.figures`: on a dissolve, the people whose order totals cannot\n * follow one site and are dropped. Read like the combine — the people\n * with orders under the key, bounded — and decided by the same split.\n */\n const dissolving = splits.filter((split) => !split.survives)\n if (dissolving.length) {\n let figures: number | null = 0\n for (const split of dissolving) {\n const page = await contacts\n .where(`${CONTACT_FACETS_FIELD}.${split.from}.ordersCount`, '>', 0)\n .limit(PREVIEW_SCAN_LIMIT + 1)\n .get()\n if (page.size > PREVIEW_SCAN_LIMIT || figures === null) {\n figures = null\n continue\n }\n for (const doc of page.docs) {\n figures +=\n planHolderRecords((doc.data() ?? {}) as Doc, { flows: [split] }, '')?.figuresDropped ?? 0\n }\n }\n lines.push({\n id: 'crm.figures',\n text: `Order totals for ${figure(figures)} ${people(figures)} who bought on more than one of these sites can't be split and will be removed.`,\n count: figures,\n severity: 'warning',\n })\n }\n }\n\n /*\n * `crm.refusals`: the refusals C4 would write — each separating pair's\n * declined consents, less the ones the receiving site already holds.\n */\n if (plan.carries.length) {\n let refusals = 0\n for (const carry of plan.carries) {\n const declined = (hostId: string) =>\n `${MARKETING_CONSENT_BY_HOST_FIELD}.${hostId}.${MARKETING_CONSENT_FIELD}`\n const from = contacts.where(declined(carry.fromHostId), '==', false)\n refusals += Math.max(\n 0,\n (await countOf(from)) - (await countOf(from.where(declined(carry.toHostId), '==', false))),\n )\n }\n lines.push({\n id: 'crm.refusals',\n text: `${refusals} declined ${refusals === 1 ? 'consent' : 'consents'} will be copied to the sites that separate.`,\n count: refusals,\n severity: 'info',\n })\n }\n return lines\n },\n\n summarize: summarizeConsentGroupChange,\n }\n}\n\n/** The participant the CRM registers, on the Admin SDK and the wall clock. */\nexport const consentGroupParticipant = createConsentGroupParticipant()\n"],"names":["mergeContactFacet","CONTACT_FACETS_FIELD","interactionsForGroup","COMPANY_CONTACTS_COUNT_FIELD","CONTACT_COMPANY_IDS_FIELD","CRM_COLLECTIONS","CAPTURED_BY_HOST_FIELD","CONSENT_GROUP_ID_FIELD","MARKETING_CONSENT_BY_HOST_FIELD","MARKETING_CONSENT_FIELD","MARKETING_CONSENT_SOURCE_FIELD","firebaseAdmin","FieldPath","FieldValue","summarizeConsentGroupChange","REFUSAL_PAGE","HOLDER_PAGE","PREVIEW_SCAN_LIMIT","HOLDER_FIGURE_FIELDS","COPIED_BY_CHANGE_FIELD","RACED","Set","codeOf","error","code","mapOf","value","Array","isArray","captureOrder","contact","raw","ordered","map","id","String","trim","filter","Boolean","legacy","includes","withoutFigures","facet","out","field","figuresOf","planHolderRecords","plan","changeId","options","facets","captured","next","current","key","undefined","combined","copied","figuresDropped","splits","flows","flow","kind","stragglersOnly","survives","copies","split","source","from","successors","some","successor","keyOf","hostId","stayHostIds","find","hostIds","eligible","length","landing","fromHostIds","figuresTo","s","copy","interactions","push","figures","Object","keys","moves","Map","set","to","get","metAt","indexes","indexOf","index","Math","min","Number","POSITIVE_INFINITY","target","sources","sort","a","b","folded","existing","merged","stamps","named","values","after","before","now","mirror","companyCounts","leaving","has","joining","companyId","delta","patch","delete","serverTimestamp","planRefusalCarry","carry","byHost","fromHostId","toHostId","entry","marketingConsentAtMs","carriedFromHostId","carriedByChangeId","supersededEntry","updatedAt","readCursor","first","parsed","JSON","parse","stage","writeGuarded","firestore","pending","recompute","written","due","round","writer","bulkWriter","outcomes","update","snapshot","ref","lastUpdateTime","updateTime","then","Error","close","raced","Promise","all","fresh","getAll","exists","createConsentGroupParticipant","db","app","Date","contactsOf","orgId","collection","doc","countOf","query","count","data","refusalPage","request","cursor","counts","carries","where","orderBy","documentId","limit","startAfter","page","docs","dryRun","size","holderPage","companies","phase","tally","planned","change","increment","catch","console","run","hasHolderFlows","deadlineMs","done","stringify","preview","contacts","lines","people","figure","seen","bounded","move","combine","reduce","total","text","severity","leavers","flatMap","slice","grants","visible","met","max","dissolving","refusals","declined","summarize","consentGroupParticipant"],"mappings":"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"}
@@ -98,7 +98,8 @@ export declare const CONTACT_BAND_FULL_MESSAGE: string;
98
98
  *
99
99
  * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,
100
100
  * companyId?, address?, ownerUid?, lifecycleStage?, tags?,
101
- * marketingConsent? }`. Answers `{ contactId, created }`: `created` is
101
+ * marketingConsent?, disclosedConsentGroup? }`. Answers
102
+ * `{ contactId, created }`: `created` is
102
103
  * false when the address already belonged to somebody, in which case what
103
104
  * was typed MERGES into the existing row — the dedupe the shared address
104
105
  * book exists for, and the reason a second "create" of one person is not
package/src/lib/server.js CHANGED
@@ -296,7 +296,8 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
296
296
  *
297
297
  * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,
298
298
  * companyId?, address?, ownerUid?, lifecycleStage?, tags?,
299
- * marketingConsent? }`. Answers `{ contactId, created }`: `created` is
299
+ * marketingConsent?, disclosedConsentGroup? }`. Answers
300
+ * `{ contactId, created }`: `created` is
300
301
  * false when the address already belonged to somebody, in which case what
301
302
  * was typed MERGES into the existing row — the dedupe the shared address
302
303
  * book exists for, and the reason a second "create" of one person is not
@@ -388,6 +389,10 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
388
389
  const address = body['address'] && typeof body['address'] === 'object' ? normalizeAddress(body['address']) : null;
389
390
  const tags = normalizeTags(body['tags']);
390
391
  const marketingConsent = body['marketingConsent'] === true;
392
+ // The key of the consent-group sentence the drawer showed beside the box
393
+ // (AGL-3320): the opt-in covers the site's group only while it is the
394
+ // group's current one, and the capturing site alone otherwise.
395
+ const disclosedConsentGroup = typed(body['disclosedConsentGroup'], 64);
391
396
  const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
392
397
  const idToken = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : undefined;
393
398
  if (!idToken) {
@@ -452,7 +457,10 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
452
457
  interaction: {
453
458
  summary: 'Added by hand'
454
459
  },
455
- marketingConsent,
460
+ marketingConsent
461
+ }, marketingConsent && disclosedConsentGroup ? {
462
+ disclosedConsentGroup
463
+ } : {}, {
456
464
  facet: _extends({}, phone ? {
457
465
  phone
458
466
  } : {}, jobTitle ? {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server.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 Contacts plugin's SERVER half (AGL-2595).\n *\n * Contacts CRM v1 had none: every console write was client-direct against\n * the Firestore rules, and the one server path that creates a contact —\n * `upsertHostContact` — belongs to the capture doors, not to this plugin.\n * v2 needs a server for the things a browser must not do alone: an import\n * that dedupes forty thousand rows, a bulk action over a selection, an\n * auto-association of contacts to companies by domain. Each of those lands\n * as a `crm/<route>` handler registered here.\n *\n * `crm/ping` exists so the wiring is PROVEN rather than assumed:\n * `plugins.config.json` names this module's register function and the\n * `crm` API prefix, the generated server manifest loads this file, and the\n * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin\n * whose first real route also had to be its first wiring test would have\n * two things to debug at once.\n *\n * The real routes are the writes with a side effect outside the document:\n * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`\n * can only be emitted by a server path that performed the write, so a record\n * page that moved the stage client-direct would move the person and tell no\n * automation; the task routes (AGL-2599) carry an assignee's notification and\n * the `taskCompleted` event; `crm/contacts-import` (AGL-2602) pushes one chunk\n * of a file through the same capture door every other server door uses.\n * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,\n * because a stage change is what automations listen for (`server-deal-stage.ts`).\n * `crm/contacts-create` (AGL-2596) is a person typed into the console by a\n * member of the team — a server route because the dedupe against every\n * holder's rows and the audience band are judgments the browser cannot make.\n * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:\n * the rules cannot tell one field of a holder's facet from another, so they\n * leave a client nothing there but letting a holder go, and the plan is\n * asked here about the fields a save carries.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n contactFacetPath,\n CRM_COLLECTIONS,\n crmReadTokens,\n isContactLifecycleStage,\n isOrgWideMember,\n normalizeAddress,\n normalizeContactEmail,\n normalizePhone,\n type AglynPostalAddress,\n type PluginApiHandler,\n type PluginApiRequest,\n readContactFacet,\n registerPluginApiRoute,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { captureHostContact, emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CRM_API_ROUTES } from './constants/api-routes'\nimport { registerCrmServerDeclarations } from './declarations.server'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { CRM_NEXT_ACTIVITY_ROUTE } from './model/next-activity'\nimport { CRM_LEAD_SOURCE_VALUES_ROUTE } from './model/lead-source-values'\nimport { CRM_TASK_ROUTES } from './model/task-routes'\nimport { crmNextActivityHandler } from './server/next-activity-routes'\nimport { crmLeadSourceValuesHandler } from './server/lead-source-values'\nimport { registerCrmRecordEmailStateWriter } from './server/record-email-state'\nimport { registerCrmRecordTimelineWriter } from './server/record-timeline'\nimport { crmTaskCompleteHandler, crmTaskSaveHandler } from './server/task-routes'\nimport { crmCompaniesImportHandler } from './server/companies-import'\nimport { crmContactsImportHandler } from './server/contacts-import'\nimport { crmDealsImportHandler } from './server/deals-import'\nimport { crmLeadsImportHandler } from './server/leads-import'\nimport { crmTasksImportHandler } from './server/tasks-import'\nimport { crmDealStageHandler } from './server-deal-stage'\nimport { crmEmailSendHandler } from './server/email-send'\nimport { leadConvertHandler } from './server/lead-convert'\nimport { leadCreateHandler } from './server/lead-create'\nimport {\n CONTACT_EMAIL_HISTORY_ROUTE,\n contactEmailHistoryHandler,\n} from './server/contact-email-history'\nimport { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from './server/contacts-merge'\nimport { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from './server/erase-person'\nimport { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from './server/org-activity'\nimport { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from './server/inbound-address'\nimport { crmCompanyDeleteHandler } from './server/company-delete'\nimport { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from './server/contact-profile'\nimport { crmContactUpdateHandler } from './server/contact-update'\nimport {\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n} from './server/email-template-duplicate'\nimport { registerCrmRecordFactsReaders } from './server/record-facts'\nimport { crmSuiteRefusal } from './server/suite-gate'\nimport {\n CRM_RECIPE_INSTALL_ROUTE,\n CRM_RECIPE_STATUS_ROUTE,\n crmRecipeInstallHandler,\n crmRecipeStatusHandler,\n} from './server/recipe-routes'\n\n/**\n * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.\n *\n * No auth, no host, no data: it answers whether the plugin's server bundle\n * was loaded and its routes registered, which is a fact about the process\n * and not about any org. Anything that reads a document goes behind the\n * same session and role checks the other plugins' console routes use.\n */\nexport const crmPingHandler: PluginApiHandler = (req, res) => {\n if (req.method !== 'GET') {\n res.setHeader('Allow', 'GET')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n res.status(200).json({ ok: true, plugin: BUNDLE_ID })\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/**\n * Who is calling, and whether they may edit this site's CRM.\n *\n * The same check the inbox, bookings and email console routes make: a\n * bearer ID token, verified, and an `admin` or `editor` role on the host\n * document. A contact is a site's business record, so the site's own role\n * map is the right authority; org-wide reach is not required, because a\n * site editor who can open the record in the console can already write\n * every other field on it client-direct.\n */\nasync function authorizeSiteEditor(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; uid: string } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let uid: string\n try {\n uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the site editor could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const hostSnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return { ok: false, status: 403, error: 'Not a site admin or editor' }\n }\n return { ok: true, uid }\n}\n\n/**\n * `POST /api/crm/contact-stage` with `{ hostId, contactId, lifecycleStage }`.\n *\n * Writes the stage into THIS site's facet on the contact — a dotted\n * `update()`, never a top-level field, because the row is shared by every\n * site in the org and a stage is one holder's reading of the person — and\n * then announces `contactStageChanged` with the stage it replaced.\n *\n * A stage set to what it already is writes nothing and announces nothing:\n * the response says `changed: false`, and no automation listening for a\n * change hears one that did not happen.\n *\n * `lifecycleStage: null` CLEARS the stage (AGL-2804). A facet is the\n * server's to write, so \"not placed yet\" comes through the route that owns\n * the stage rather than as a client-direct delete, and it is refused to a\n * plan without the suite the way a move is. There is no event for \"no\n * stage\", so a clear announces nothing; an empty string names no stage and\n * is refused like any other the list does not have.\n *\n * The contact is looked up by id and then checked against `visibleTo`,\n * because the Admin SDK evaluates no rules: a caller who can edit site A\n * must not be able to restage a contact only site B holds by guessing its\n * id, and the scoped query the list uses is not available to a `doc()` read.\n */\nexport const contactStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n const requested: unknown = req.body?.lifecycleStage\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n // `null` clears the stage; anything else has to be one of the list's.\n const clearing = requested === null\n const lifecycleStage = clearing\n ? ''\n : isContactLifecycleStage(requested)\n ? requested\n : null\n if (lifecycleStage === null) {\n res.status(400).json({ error: 'Pick a lifecycle stage' })\n return\n }\n const caller = await authorizeSiteEditor(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n try {\n // A lifecycle stage is the suite's (AGL-2787) — see `suite-gate.ts`.\n const owner = await getOrgForHost(hostId)\n if (!owner) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(owner.org, \"Moving a contact's lifecycle stage\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n if (!snapshot.exists || !visibleToHost(snapshot.get('visibleTo'), hostId)) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(\n snapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const previousStage = facet.lifecycleStage ?? ''\n if (previousStage === lifecycleStage) {\n res\n .status(200)\n .json({ ok: true, changed: false, lifecycleStage, previousStage })\n return\n }\n await snapshot.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: clearing\n ? FieldValue.delete()\n : lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Awaited, not floated: a serverless response ending cancels in-flight\n // work, and the event is the reason this route exists. A clear has none.\n if (!clearing) {\n await emitHostEvent(hostId, 'contactStageChanged', {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n })\n }\n res\n .status(200)\n .json({ ok: true, changed: true, lifecycleStage, previousStage })\n } catch (error) {\n console.error('[crm] contact-stage failed', hostId, contactId, error)\n res.status(500).json({ error: 'The stage could not be changed' })\n }\n}\n\n/**\n * What the create route says when the band refused (AGL-2596).\n *\n * The contacts list's own alert, in the past tense: the list says new\n * visitors are \"no longer captured\", and this says the one contact the\n * reader just tried to add was not. The remedy is the same sentence in both\n * places, so a reader who sees one and then the other is told one thing.\n */\nexport const CONTACT_BAND_FULL_MESSAGE =\n 'Contact limit reached — this contact was not added. Upgrade in Billing ' +\n 'to keep collecting.'\n\n/**\n * `POST /api/crm/contacts-create` — a person added by hand (AGL-2596).\n *\n * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,\n * companyId?, address?, ownerUid?, lifecycleStage?, tags?,\n * marketingConsent? }`. Answers `{ contactId, created }`: `created` is\n * false when the address already belonged to somebody, in which case what\n * was typed MERGES into the existing row — the dedupe the shared address\n * book exists for, and the reason a second \"create\" of one person is not\n * an error.\n *\n * ## Who may call it\n *\n * The caller's ID token, then `data.manage` on the site's org — the same key\n * the console surface itself is gated on, resolved through the same\n * three-layer permission read the members route uses. A scoped member is\n * admitted only for a site they reach: the rules would refuse a browser\n * write outside their tokens, and a server route that admitted it would be\n * a way round the rules rather than a service in front of them.\n *\n * ## What it refuses, and how\n *\n * A malformed email or phone number is a 400 with a sentence the drawer can\n * show under the field. The band is a 409 carrying the list's own wording:\n * it is not a bad request and not a server fault, it is the plan saying no,\n * and the drawer relays the sentence rather than inventing one.\n *\n * ## The company (AGL-2613)\n *\n * `companyId` is the picker's choice, and it is checked before anything is\n * written: the company has to exist and be visible to the capturing site's\n * scope, or a caller could file a person under a record they cannot open —\n * the same refusal the lead conversion makes. It reaches the upsert as\n * `facet.companyId`, which is where the link is kept in step with its mirror\n * and the company's contacts count; a merge onto somebody already at another\n * company is a MOVE there, not a second link. The stored company's own name\n * is what is echoed, over whatever the client sent, because the name on the\n * record is the company's and not the form's.\n *\n * ## What it writes beyond the upsert\n *\n * `upsertHostContact` takes the profile — phone, title, address, owner,\n * stage, company — and writes it into the capturing group's facet. The two\n * things it does not take are written here by DOTTED path afterwards: the\n * tags, which union into whatever the person already carried, and the\n * company name — the picked company's, or free text for a person filed\n * under a name no company record carries yet. Dotted paths because this is\n * an `update()`, and only an update reads a dot as a path — a nested object\n * here would replace the facet map and take every other holder's records\n * with it.\n */\nexport const crmContactsCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n const hostId = typed(body['hostId'], 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body['email'])\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n const rawPhone = typed(body['phone'], 40)\n const phone = rawPhone ? normalizePhone(rawPhone) : null\n if (rawPhone && !phone) {\n res.status(400).json({ error: CONTACT_PHONE_REFUSAL })\n return\n }\n const rawStage = typed(body['lifecycleStage'], 40)\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n res.status(400).json({ error: 'Unknown lifecycle stage.' })\n return\n }\n const name = typed(body['name'], 120)\n const jobTitle = typed(body['jobTitle'], 120)\n let companyName = typed(body['companyName'], 120)\n const companyId = typed(body['companyId'], 128)\n const ownerUid = typed(body['ownerUid'], 128)\n const address =\n body['address'] && typeof body['address'] === 'object'\n ? normalizeAddress(body['address'] as AglynPostalAddress)\n : null\n const tags = normalizeTags(body['tags'])\n const marketingConsent = body['marketingConsent'] === true\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const isStaff = decoded['staff'] === true\n if (!isStaff) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId)\n const member = membership?.member ?? null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) {\n res.status(403).json({\n error: 'Adding contacts requires the data.manage permission on this site',\n })\n return\n }\n }\n\n // A person typed in by the team is the suite's; the capture doors that\n // fill a Free workspace's contacts do not come through here (AGL-2787).\n const suite = crmSuiteRefusal(resolved.org, 'Adding a contact by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n if (companyId) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const readable = new Set<string>(crmReadTokens(group))\n const companySnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .get()\n const tokens: unknown = companySnapshot.exists\n ? companySnapshot.get('visibleTo')\n : undefined\n const visible =\n Array.isArray(tokens) && tokens.some((token) => readable.has(String(token)))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n companyName = typed(companySnapshot.get('name'), 120) || companyName\n }\n\n const result = await captureHostContact({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'manual',\n interaction: { summary: 'Added by hand' },\n marketingConsent,\n facet: {\n ...(phone ? { phone } : {}),\n ...(jobTitle ? { jobTitle } : {}),\n ...(companyId ? { companyId } : {}),\n ...(address ? { address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(rawStage && isContactLifecycleStage(rawStage)\n ? { lifecycleStage: rawStage }\n : {}),\n },\n })\n\n if ('refused' in result) {\n if (result.refused === 'band') {\n res.status(409).json({ error: CONTACT_BAND_FULL_MESSAGE, reason: 'band' })\n return\n }\n if (result.refused === 'invalid-email') {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n // The person was erased from this workspace (AGL-2623). A conflict,\n // like the band: the request was well-formed and the answer is a\n // standing fact about the address, not a fault in the call.\n if (result.refused === 'erased') {\n res.status(409).json({ error: CONTACT_ERASED_MESSAGE, reason: 'erased' })\n return\n }\n res.status(500).json({ error: 'The contact could not be saved.' })\n return\n }\n\n if (tags.length || companyName) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n await contacts.doc(result.contactId).update({\n ...(tags.length\n ? {\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(\n ...tags,\n ),\n }\n : {}),\n ...(companyName\n ? {\n [contactFacetPath(group.groupId, 'companyName')]: companyName,\n // The search echo — see `HostContact.companyName`.\n companyName,\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n\n /*\n * The audit line, written HERE rather than by the console (AGL-2622):\n * a route that verified the caller and performed the write is the one\n * writer that cannot record an act that did not happen, which is the\n * reason `check-activity-coverage.mjs` counts this file. A merge into\n * a person the org already held is said to be one — the row was\n * updated, not added — so the feed cannot claim two people for one.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n result.created ? 'Added contact' : 'Updated contact',\n { type: 'contact', id: result.contactId, name: name || email },\n )\n\n res\n .status(result.created ? 201 : 200)\n .json({ contactId: result.contactId, created: result.created })\n } catch (error) {\n console.error('[crm] contact create failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n\n/** Console API registration, named in `plugins.config.json` as `consoleApi`. */\nexport function registerCrmConsoleApi(): void {\n /*\n * The contact-capture writer, again (AGL-3080). Both apps run it at boot\n * from their generated server-declarations manifest, and this is the\n * backstop for a process whose boot did not: the first CRM door to load\n * registers it. Registering twice replaces in place.\n */\n registerCrmServerDeclarations()\n registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler)\n // Every other console write to a contact's facets (AGL-2804): the rules\n // cannot tell one facet field from another, so the plan is asked here.\n registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler)\n // A company's contacts unlinked, then the company (AGL-2804): the unlink\n // clears a facet, which is the server's to write.\n registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler)\n // Tasks (AGL-2599): the two writes with a side effect outside the document\n // — an assignee's notification, and the `taskCompleted` host event.\n registerPluginApiRoute(CRM_TASK_ROUTES.save, crmTaskSaveHandler)\n registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler)\n // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).\n registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler)\n // A lead source value renamed or deleted (AGL-3298): the list and every\n // lead and contact holding the old label, in one request.\n registerPluginApiRoute(CRM_LEAD_SOURCE_VALUES_ROUTE, crmLeadSourceValuesHandler)\n // One chunk of a contact file (AGL-2602), judged and written through the\n // same door every capture uses.\n registerPluginApiRoute('crm/contacts-import', crmContactsImportHandler)\n // One chunk of a companies file (AGL-2621), matched by domain then name\n // and written with the stamp every CRM creator writes.\n registerPluginApiRoute('crm/companies-import', crmCompaniesImportHandler)\n // One chunk of a deals file and one of a tasks file (AGL-2662): the\n // pipeline, the stage and the assignee resolved by name, a row refused\n // when the org has no such name.\n registerPluginApiRoute('crm/deals-import', crmDealsImportHandler)\n registerPluginApiRoute('crm/tasks-import', crmTasksImportHandler)\n // One chunk of a leads file (AGL-2701), written through `addHostLead` —\n // the same door a sign-up, a booking and a form submission file a lead\n // through, so an imported row is keyed, bounded and unconsented exactly\n // as a captured one is.\n registerPluginApiRoute('crm/leads-import', crmLeadsImportHandler)\n // The one writer of a deal's stage, won and lost (AGL-2598): the browser\n // could write the field, but only a server can emit the event an\n // automation listens for.\n registerPluginApiRoute('crm/deal-stage', crmDealStageHandler)\n // A person typed into the console (AGL-2596): the capture doors' own\n // function behind a session check, so the dedupe and the band are judged\n // where every other door judges them.\n registerPluginApiRoute('crm/contacts-create', crmContactsCreateHandler)\n // A lead becomes a contact, a company and a deal (AGL-2608) — the one CRM\n // write a browser cannot make alone, because only the server may create a\n // contact through the dedupe-and-meter door.\n registerPluginApiRoute('crm/lead-convert', leadConvertHandler)\n // A lead entered by hand (AGL-3231): the New lead drawer's route.\n registerPluginApiRoute('crm/leads-create', leadCreateHandler)\n // The one READ behind a route (AGL-2616): the per-recipient delivery log\n // is closed to clients, so a contact's campaign mail is projected here.\n registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler)\n // One email to one person from their record (AGL-2615): the recipient is\n // read off the record, the daily cap and both suppression lists are\n // judged, and the message leaves on the site's sending identity.\n registerPluginApiRoute(CRM_API_ROUTES.emailSend, crmEmailSendHandler)\n // Two records for one person become one (AGL-2625): the repoint of every\n // row naming the merged record and the transaction over both documents\n // are the server's, and the address index it writes is closed to clients.\n registerPluginApiRoute(CONTACTS_MERGE_ROUTE, contactsMergeHandler)\n // Files a person's privacy erasure for the daily job (AGL-2623);\n // workspace admins only.\n registerPluginApiRoute(CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler)\n // One line in the organization's activity feed for an act the org-level\n // hub performed client-direct (AGL-2634): the feed is closed to clients,\n // so the bulk bars' lines come through here.\n registerPluginApiRoute(CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler)\n // A recipe's action written into one site's automations from the org\n // hub (AGL-2639), stamped and deduplicated by the server, and the stamps\n // read back per site so the hub can say which sites carry which recipe.\n registerPluginApiRoute(CRM_RECIPE_INSTALL_ROUTE, crmRecipeInstallHandler)\n registerPluginApiRoute(CRM_RECIPE_STATUS_ROUTE, crmRecipeStatusHandler)\n // The workspace's email capture address (AGL-2657): the token is minted\n // and rotated here, behind the CRM's own gate, and the org document that\n // carries it is closed to every client.\n registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler)\n registerPluginApiRoute(\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n )\n // The facts of a contact, company, deal or lead, and an import's field\n // catalog, for another plugin in this process (AGL-2917): read on the core's\n // record-facts seam under the rules every CRM door applies, and written\n // nowhere.\n registerCrmRecordFactsReaders()\n // The CRM as the workspace's record system on the core's record-timeline\n // seam (AGL-2981): another plugin files an email, a reply or a task on a\n // record through it, under the CRM's own scope, dedupe and ceiling.\n registerCrmRecordTimelineWriter()\n // And on the record email-state seam (AGL-3245): a sender that filed a\n // verdict on an address — a bounce, a complaint, an unsubscribe, a\n // do-not-contact mark — stamps the lead and the contact through it.\n registerCrmRecordEmailStateWriter()\n}\n"],"names":["CONTACT_ERASED_MESSAGE","contactFacetPath","CRM_COLLECTIONS","crmReadTokens","isContactLifecycleStage","isOrgWideMember","normalizeAddress","normalizeContactEmail","normalizePhone","readContactFacet","registerPluginApiRoute","visibleToHost","consentGroupForSite","firebaseAdmin","getOrgForHost","logHostActivity","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","isRefusedIdToken","captureHostContact","emitHostEvent","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_LEAD_SOURCE_VALUES_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","crmLeadSourceValuesHandler","registerCrmRecordEmailStateWriter","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmCompaniesImportHandler","crmContactsImportHandler","crmDealsImportHandler","crmLeadsImportHandler","crmTasksImportHandler","crmDealStageHandler","crmEmailSendHandler","leadConvertHandler","leadCreateHandler","CONTACT_EMAIL_HISTORY_ROUTE","contactEmailHistoryHandler","CONTACTS_MERGE_ROUTE","contactsMergeHandler","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","CRM_ORG_ACTIVITY_ROUTE","crmOrgActivityHandler","CRM_INBOUND_ADDRESS_ROUTE","crmInboundAddressHandler","crmCompanyDeleteHandler","CONTACT_PHONE_REFUSAL","normalizeTags","typed","crmContactUpdateHandler","CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE","crmEmailTemplateDuplicateHandler","registerCrmRecordFactsReaders","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","CRM_RECIPE_STATUS_ROUTE","crmRecipeInstallHandler","crmRecipeStatusHandler","crmPingHandler","req","res","method","setHeader","status","json","error","ok","plugin","authorizeSiteEditor","hostId","hostSnapshot","authorization","String","headers","idToken","startsWith","slice","length","undefined","uid","app","auth","verifyIdToken","console","firestore","collection","doc","get","exists","memberRole","contactStageHandler","body","trim","contactId","requested","lifecycleStage","clearing","caller","facet","owner","suite","org","contactsRef","snapshot","group","data","groupId","previousStage","changed","ref","update","delete","updatedAt","serverTimestamp","email","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","decoded","resolved","isStaff","member","membership","orgId","reaches","Boolean","hostAccess","allowed","readable","Set","companySnapshot","companies","tokens","visible","Array","isArray","some","token","has","result","source","interaction","summary","refused","reason","contacts","arrayUnion","created","type","id","registerCrmConsoleApi","ping","contactStage","contactUpdate","companyDelete","save","complete","emailSend"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server.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 Contacts plugin's SERVER half (AGL-2595).\n *\n * Contacts CRM v1 had none: every console write was client-direct against\n * the Firestore rules, and the one server path that creates a contact —\n * `upsertHostContact` — belongs to the capture doors, not to this plugin.\n * v2 needs a server for the things a browser must not do alone: an import\n * that dedupes forty thousand rows, a bulk action over a selection, an\n * auto-association of contacts to companies by domain. Each of those lands\n * as a `crm/<route>` handler registered here.\n *\n * `crm/ping` exists so the wiring is PROVEN rather than assumed:\n * `plugins.config.json` names this module's register function and the\n * `crm` API prefix, the generated server manifest loads this file, and the\n * console's `/api/[...pluginApi]` dispatcher reaches the handler. A plugin\n * whose first real route also had to be its first wiring test would have\n * two things to debug at once.\n *\n * The real routes are the writes with a side effect outside the document:\n * `crm/contact-stage` (AGL-2605) exists for the EVENT — `contactStageChanged`\n * can only be emitted by a server path that performed the write, so a record\n * page that moved the stage client-direct would move the person and tell no\n * automation; the task routes (AGL-2599) carry an assignee's notification and\n * the `taskCompleted` event; `crm/contacts-import` (AGL-2602) pushes one chunk\n * of a file through the same capture door every other server door uses.\n * `crm/deal-stage` (AGL-2598) is the one writer of a deal's stage, won and lost,\n * because a stage change is what automations listen for (`server-deal-stage.ts`).\n * `crm/contacts-create` (AGL-2596) is a person typed into the console by a\n * member of the team — a server route because the dedupe against every\n * holder's rows and the audience band are judgments the browser cannot make.\n * `crm/contact-update` (AGL-2804) is every other write to a contact's facets:\n * the rules cannot tell one field of a holder's facet from another, so they\n * leave a client nothing there but letting a holder go, and the plan is\n * asked here about the fields a save carries.\n */\n\nimport {\n CONTACT_ERASED_MESSAGE,\n contactFacetPath,\n CRM_COLLECTIONS,\n crmReadTokens,\n isContactLifecycleStage,\n isOrgWideMember,\n normalizeAddress,\n normalizeContactEmail,\n normalizePhone,\n type AglynPostalAddress,\n type PluginApiHandler,\n type PluginApiRequest,\n readContactFacet,\n registerPluginApiRoute,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n logHostActivity,\n memberHasOrgPermission,\n orgDataCollectionForHost,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { captureHostContact, emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { CRM_API_ROUTES } from './constants/api-routes'\nimport { registerCrmServerDeclarations } from './declarations.server'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { CRM_NEXT_ACTIVITY_ROUTE } from './model/next-activity'\nimport { CRM_LEAD_SOURCE_VALUES_ROUTE } from './model/lead-source-values'\nimport { CRM_TASK_ROUTES } from './model/task-routes'\nimport { crmNextActivityHandler } from './server/next-activity-routes'\nimport { crmLeadSourceValuesHandler } from './server/lead-source-values'\nimport { registerCrmRecordEmailStateWriter } from './server/record-email-state'\nimport { registerCrmRecordTimelineWriter } from './server/record-timeline'\nimport { crmTaskCompleteHandler, crmTaskSaveHandler } from './server/task-routes'\nimport { crmCompaniesImportHandler } from './server/companies-import'\nimport { crmContactsImportHandler } from './server/contacts-import'\nimport { crmDealsImportHandler } from './server/deals-import'\nimport { crmLeadsImportHandler } from './server/leads-import'\nimport { crmTasksImportHandler } from './server/tasks-import'\nimport { crmDealStageHandler } from './server-deal-stage'\nimport { crmEmailSendHandler } from './server/email-send'\nimport { leadConvertHandler } from './server/lead-convert'\nimport { leadCreateHandler } from './server/lead-create'\nimport {\n CONTACT_EMAIL_HISTORY_ROUTE,\n contactEmailHistoryHandler,\n} from './server/contact-email-history'\nimport { CONTACTS_MERGE_ROUTE, contactsMergeHandler } from './server/contacts-merge'\nimport { CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler } from './server/erase-person'\nimport { CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler } from './server/org-activity'\nimport { CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler } from './server/inbound-address'\nimport { crmCompanyDeleteHandler } from './server/company-delete'\nimport { CONTACT_PHONE_REFUSAL, normalizeTags, typed } from './server/contact-profile'\nimport { crmContactUpdateHandler } from './server/contact-update'\nimport {\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n} from './server/email-template-duplicate'\nimport { registerCrmRecordFactsReaders } from './server/record-facts'\nimport { crmSuiteRefusal } from './server/suite-gate'\nimport {\n CRM_RECIPE_INSTALL_ROUTE,\n CRM_RECIPE_STATUS_ROUTE,\n crmRecipeInstallHandler,\n crmRecipeStatusHandler,\n} from './server/recipe-routes'\n\n/**\n * `GET /api/crm/ping` → `{ ok: true, plugin: 'crm' }`.\n *\n * No auth, no host, no data: it answers whether the plugin's server bundle\n * was loaded and its routes registered, which is a fact about the process\n * and not about any org. Anything that reads a document goes behind the\n * same session and role checks the other plugins' console routes use.\n */\nexport const crmPingHandler: PluginApiHandler = (req, res) => {\n if (req.method !== 'GET') {\n res.setHeader('Allow', 'GET')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n res.status(200).json({ ok: true, plugin: BUNDLE_ID })\n}\n\ntype Refusal = { ok: false; status: number; error: string }\n\n/**\n * Who is calling, and whether they may edit this site's CRM.\n *\n * The same check the inbox, bookings and email console routes make: a\n * bearer ID token, verified, and an `admin` or `editor` role on the host\n * document. A contact is a site's business record, so the site's own role\n * map is the right authority; org-wide reach is not required, because a\n * site editor who can open the record in the console can already write\n * every other field on it client-direct.\n */\nasync function authorizeSiteEditor(\n req: PluginApiRequest,\n hostId: string,\n): Promise<{ ok: true; uid: string } | Refusal> {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return { ok: false, status: 401, error: 'Unauthenticated' }\n let uid: string\n try {\n uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid\n } catch (error) {\n // A refused credential is the caller's 401; a failure to check one is\n // ours and keeps a 5xx (AGL-2852).\n if (isRefusedIdToken(error)) return { ok: false, status: 401, error: 'Unauthenticated' }\n console.error('[crm] the site editor could not be verified', error)\n return { ok: false, status: 500, error: 'The sign-in could not be checked. Try again.' }\n }\n const hostSnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, error: 'Unknown site' }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[uid]\n if (memberRole !== 'admin' && memberRole !== 'editor') {\n return { ok: false, status: 403, error: 'Not a site admin or editor' }\n }\n return { ok: true, uid }\n}\n\n/**\n * `POST /api/crm/contact-stage` with `{ hostId, contactId, lifecycleStage }`.\n *\n * Writes the stage into THIS site's facet on the contact — a dotted\n * `update()`, never a top-level field, because the row is shared by every\n * site in the org and a stage is one holder's reading of the person — and\n * then announces `contactStageChanged` with the stage it replaced.\n *\n * A stage set to what it already is writes nothing and announces nothing:\n * the response says `changed: false`, and no automation listening for a\n * change hears one that did not happen.\n *\n * `lifecycleStage: null` CLEARS the stage (AGL-2804). A facet is the\n * server's to write, so \"not placed yet\" comes through the route that owns\n * the stage rather than as a client-direct delete, and it is refused to a\n * plan without the suite the way a move is. There is no event for \"no\n * stage\", so a clear announces nothing; an empty string names no stage and\n * is refused like any other the list does not have.\n *\n * The contact is looked up by id and then checked against `visibleTo`,\n * because the Admin SDK evaluates no rules: a caller who can edit site A\n * must not be able to restage a contact only site B holds by guessing its\n * id, and the scoped query the list uses is not available to a `doc()` read.\n */\nexport const contactStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n const hostId = String(req.body?.hostId ?? '').trim()\n const contactId = String(req.body?.contactId ?? '').trim()\n const requested: unknown = req.body?.lifecycleStage\n if (!hostId || !contactId) {\n res.status(400).json({ error: 'Missing hostId or contactId' })\n return\n }\n // `null` clears the stage; anything else has to be one of the list's.\n const clearing = requested === null\n const lifecycleStage = clearing\n ? ''\n : isContactLifecycleStage(requested)\n ? requested\n : null\n if (lifecycleStage === null) {\n res.status(400).json({ error: 'Pick a lifecycle stage' })\n return\n }\n const caller = await authorizeSiteEditor(req, hostId)\n if (caller.ok === false) {\n res.status(caller.status).json({ error: caller.error })\n return\n }\n try {\n // A lifecycle stage is the suite's (AGL-2787) — see `suite-gate.ts`.\n const owner = await getOrgForHost(hostId)\n if (!owner) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const suite = crmSuiteRefusal(owner.org, \"Moving a contact's lifecycle stage\")\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n const snapshot = await contactsRef.doc(contactId).get()\n if (!snapshot.exists || !visibleToHost(snapshot.get('visibleTo'), hostId)) {\n res.status(404).json({ error: 'Unknown contact' })\n return\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(\n snapshot.data() as Record<string, unknown>,\n group.groupId,\n )\n const previousStage = facet.lifecycleStage ?? ''\n if (previousStage === lifecycleStage) {\n res\n .status(200)\n .json({ ok: true, changed: false, lifecycleStage, previousStage })\n return\n }\n await snapshot.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: clearing\n ? FieldValue.delete()\n : lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Awaited, not floated: a serverless response ending cancels in-flight\n // work, and the event is the reason this route exists. A clear has none.\n if (!clearing) {\n await emitHostEvent(hostId, 'contactStageChanged', {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n })\n }\n res\n .status(200)\n .json({ ok: true, changed: true, lifecycleStage, previousStage })\n } catch (error) {\n console.error('[crm] contact-stage failed', hostId, contactId, error)\n res.status(500).json({ error: 'The stage could not be changed' })\n }\n}\n\n/**\n * What the create route says when the band refused (AGL-2596).\n *\n * The contacts list's own alert, in the past tense: the list says new\n * visitors are \"no longer captured\", and this says the one contact the\n * reader just tried to add was not. The remedy is the same sentence in both\n * places, so a reader who sees one and then the other is told one thing.\n */\nexport const CONTACT_BAND_FULL_MESSAGE =\n 'Contact limit reached — this contact was not added. Upgrade in Billing ' +\n 'to keep collecting.'\n\n/**\n * `POST /api/crm/contacts-create` — a person added by hand (AGL-2596).\n *\n * Body: `{ hostId, email, name?, phone?, jobTitle?, companyName?,\n * companyId?, address?, ownerUid?, lifecycleStage?, tags?,\n * marketingConsent?, disclosedConsentGroup? }`. Answers\n * `{ contactId, created }`: `created` is\n * false when the address already belonged to somebody, in which case what\n * was typed MERGES into the existing row — the dedupe the shared address\n * book exists for, and the reason a second \"create\" of one person is not\n * an error.\n *\n * ## Who may call it\n *\n * The caller's ID token, then `data.manage` on the site's org — the same key\n * the console surface itself is gated on, resolved through the same\n * three-layer permission read the members route uses. A scoped member is\n * admitted only for a site they reach: the rules would refuse a browser\n * write outside their tokens, and a server route that admitted it would be\n * a way round the rules rather than a service in front of them.\n *\n * ## What it refuses, and how\n *\n * A malformed email or phone number is a 400 with a sentence the drawer can\n * show under the field. The band is a 409 carrying the list's own wording:\n * it is not a bad request and not a server fault, it is the plan saying no,\n * and the drawer relays the sentence rather than inventing one.\n *\n * ## The company (AGL-2613)\n *\n * `companyId` is the picker's choice, and it is checked before anything is\n * written: the company has to exist and be visible to the capturing site's\n * scope, or a caller could file a person under a record they cannot open —\n * the same refusal the lead conversion makes. It reaches the upsert as\n * `facet.companyId`, which is where the link is kept in step with its mirror\n * and the company's contacts count; a merge onto somebody already at another\n * company is a MOVE there, not a second link. The stored company's own name\n * is what is echoed, over whatever the client sent, because the name on the\n * record is the company's and not the form's.\n *\n * ## What it writes beyond the upsert\n *\n * `upsertHostContact` takes the profile — phone, title, address, owner,\n * stage, company — and writes it into the capturing group's facet. The two\n * things it does not take are written here by DOTTED path afterwards: the\n * tags, which union into whatever the person already carried, and the\n * company name — the picked company's, or free text for a person filed\n * under a name no company record carries yet. Dotted paths because this is\n * an `update()`, and only an update reads a dot as a path — a nested object\n * here would replace the facet map and take every other holder's records\n * with it.\n */\nexport const crmContactsCreateHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n res.status(405).json({ error: 'Method not allowed' })\n return\n }\n\n const body = (req.body ?? {}) as Record<string, unknown>\n const hostId = typed(body['hostId'], 128)\n if (!hostId) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const email = normalizeContactEmail(body['email'])\n if (!email) {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n const rawPhone = typed(body['phone'], 40)\n const phone = rawPhone ? normalizePhone(rawPhone) : null\n if (rawPhone && !phone) {\n res.status(400).json({ error: CONTACT_PHONE_REFUSAL })\n return\n }\n const rawStage = typed(body['lifecycleStage'], 40)\n if (rawStage && !isContactLifecycleStage(rawStage)) {\n res.status(400).json({ error: 'Unknown lifecycle stage.' })\n return\n }\n const name = typed(body['name'], 120)\n const jobTitle = typed(body['jobTitle'], 120)\n let companyName = typed(body['companyName'], 120)\n const companyId = typed(body['companyId'], 128)\n const ownerUid = typed(body['ownerUid'], 128)\n const address =\n body['address'] && typeof body['address'] === 'object'\n ? normalizeAddress(body['address'] as AglynPostalAddress)\n : null\n const tags = normalizeTags(body['tags'])\n const marketingConsent = body['marketingConsent'] === true\n // The key of the consent-group sentence the drawer showed beside the box\n // (AGL-3320): the opt-in covers the site's group only while it is the\n // group's current one, and the capturing site alone otherwise.\n const disclosedConsentGroup = typed(body['disclosedConsentGroup'], 64)\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) {\n res.status(401).json({ error: 'Unauthenticated' })\n return\n }\n\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const resolved = await getOrgForHost(hostId)\n if (!resolved) {\n res.status(404).json({ error: 'Unknown site' })\n return\n }\n const isStaff = decoded['staff'] === true\n if (!isStaff) {\n const membership = await resolveOrgMembership(decoded.uid, resolved.orgId)\n const member = membership?.member ?? null\n const reaches =\n isOrgWideMember(member) || Boolean(member?.hostAccess?.[hostId])\n const allowed =\n member &&\n reaches &&\n (await memberHasOrgPermission(resolved.orgId, member, 'data.manage'))\n if (!allowed) {\n res.status(403).json({\n error: 'Adding contacts requires the data.manage permission on this site',\n })\n return\n }\n }\n\n // A person typed in by the team is the suite's; the capture doors that\n // fill a Free workspace's contacts do not come through here (AGL-2787).\n const suite = crmSuiteRefusal(resolved.org, 'Adding a contact by hand')\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n if (companyId) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const readable = new Set<string>(crmReadTokens(group))\n const companySnapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(resolved.orgId)\n .collection(CRM_COLLECTIONS.companies)\n .doc(companyId)\n .get()\n const tokens: unknown = companySnapshot.exists\n ? companySnapshot.get('visibleTo')\n : undefined\n const visible =\n Array.isArray(tokens) && tokens.some((token) => readable.has(String(token)))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n companyName = typed(companySnapshot.get('name'), 120) || companyName\n }\n\n const result = await captureHostContact({\n hostId,\n email,\n ...(name ? { name } : {}),\n source: 'manual',\n interaction: { summary: 'Added by hand' },\n marketingConsent,\n ...(marketingConsent && disclosedConsentGroup\n ? { disclosedConsentGroup }\n : {}),\n facet: {\n ...(phone ? { phone } : {}),\n ...(jobTitle ? { jobTitle } : {}),\n ...(companyId ? { companyId } : {}),\n ...(address ? { address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(rawStage && isContactLifecycleStage(rawStage)\n ? { lifecycleStage: rawStage }\n : {}),\n },\n })\n\n if ('refused' in result) {\n if (result.refused === 'band') {\n res.status(409).json({ error: CONTACT_BAND_FULL_MESSAGE, reason: 'band' })\n return\n }\n if (result.refused === 'invalid-email') {\n res.status(400).json({ error: 'Enter a valid email address.' })\n return\n }\n // The person was erased from this workspace (AGL-2623). A conflict,\n // like the band: the request was well-formed and the answer is a\n // standing fact about the address, not a fault in the call.\n if (result.refused === 'erased') {\n res.status(409).json({ error: CONTACT_ERASED_MESSAGE, reason: 'erased' })\n return\n }\n res.status(500).json({ error: 'The contact could not be saved.' })\n return\n }\n\n if (tags.length || companyName) {\n const group = await consentGroupForSite(\n hostId,\n resolved.org as Record<string, unknown>,\n )\n const contacts = await orgDataCollectionForHost(hostId, 'contacts')\n await contacts.doc(result.contactId).update({\n ...(tags.length\n ? {\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(\n ...tags,\n ),\n }\n : {}),\n ...(companyName\n ? {\n [contactFacetPath(group.groupId, 'companyName')]: companyName,\n // The search echo — see `HostContact.companyName`.\n companyName,\n }\n : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n\n /*\n * The audit line, written HERE rather than by the console (AGL-2622):\n * a route that verified the caller and performed the write is the one\n * writer that cannot record an act that did not happen, which is the\n * reason `check-activity-coverage.mjs` counts this file. A merge into\n * a person the org already held is said to be one — the row was\n * updated, not added — so the feed cannot claim two people for one.\n */\n await logHostActivity(\n hostId,\n { uid: decoded.uid, email: decoded.email ?? null },\n result.created ? 'Added contact' : 'Updated contact',\n { type: 'contact', id: result.contactId, name: name || email },\n )\n\n res\n .status(result.created ? 201 : 200)\n .json({ contactId: result.contactId, created: result.created })\n } catch (error) {\n console.error('[crm] contact create failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n\n/** Console API registration, named in `plugins.config.json` as `consoleApi`. */\nexport function registerCrmConsoleApi(): void {\n /*\n * The contact-capture writer, again (AGL-3080). Both apps run it at boot\n * from their generated server-declarations manifest, and this is the\n * backstop for a process whose boot did not: the first CRM door to load\n * registers it. Registering twice replaces in place.\n */\n registerCrmServerDeclarations()\n registerPluginApiRoute(CRM_API_ROUTES.ping, crmPingHandler)\n registerPluginApiRoute(CRM_API_ROUTES.contactStage, contactStageHandler)\n // Every other console write to a contact's facets (AGL-2804): the rules\n // cannot tell one facet field from another, so the plan is asked here.\n registerPluginApiRoute(CRM_API_ROUTES.contactUpdate, crmContactUpdateHandler)\n // A company's contacts unlinked, then the company (AGL-2804): the unlink\n // clears a facet, which is the server's to write.\n registerPluginApiRoute(CRM_API_ROUTES.companyDelete, crmCompanyDeleteHandler)\n // Tasks (AGL-2599): the two writes with a side effect outside the document\n // — an assignee's notification, and the `taskCompleted` host event.\n registerPluginApiRoute(CRM_TASK_ROUTES.save, crmTaskSaveHandler)\n registerPluginApiRoute(CRM_TASK_ROUTES.complete, crmTaskCompleteHandler)\n // A client-direct task write's door to `nextTaskAtMs` (AGL-2661).\n registerPluginApiRoute(CRM_NEXT_ACTIVITY_ROUTE, crmNextActivityHandler)\n // A lead source value renamed or deleted (AGL-3298): the list and every\n // lead and contact holding the old label, in one request.\n registerPluginApiRoute(CRM_LEAD_SOURCE_VALUES_ROUTE, crmLeadSourceValuesHandler)\n // One chunk of a contact file (AGL-2602), judged and written through the\n // same door every capture uses.\n registerPluginApiRoute('crm/contacts-import', crmContactsImportHandler)\n // One chunk of a companies file (AGL-2621), matched by domain then name\n // and written with the stamp every CRM creator writes.\n registerPluginApiRoute('crm/companies-import', crmCompaniesImportHandler)\n // One chunk of a deals file and one of a tasks file (AGL-2662): the\n // pipeline, the stage and the assignee resolved by name, a row refused\n // when the org has no such name.\n registerPluginApiRoute('crm/deals-import', crmDealsImportHandler)\n registerPluginApiRoute('crm/tasks-import', crmTasksImportHandler)\n // One chunk of a leads file (AGL-2701), written through `addHostLead` —\n // the same door a sign-up, a booking and a form submission file a lead\n // through, so an imported row is keyed, bounded and unconsented exactly\n // as a captured one is.\n registerPluginApiRoute('crm/leads-import', crmLeadsImportHandler)\n // The one writer of a deal's stage, won and lost (AGL-2598): the browser\n // could write the field, but only a server can emit the event an\n // automation listens for.\n registerPluginApiRoute('crm/deal-stage', crmDealStageHandler)\n // A person typed into the console (AGL-2596): the capture doors' own\n // function behind a session check, so the dedupe and the band are judged\n // where every other door judges them.\n registerPluginApiRoute('crm/contacts-create', crmContactsCreateHandler)\n // A lead becomes a contact, a company and a deal (AGL-2608) — the one CRM\n // write a browser cannot make alone, because only the server may create a\n // contact through the dedupe-and-meter door.\n registerPluginApiRoute('crm/lead-convert', leadConvertHandler)\n // A lead entered by hand (AGL-3231): the New lead drawer's route.\n registerPluginApiRoute('crm/leads-create', leadCreateHandler)\n // The one READ behind a route (AGL-2616): the per-recipient delivery log\n // is closed to clients, so a contact's campaign mail is projected here.\n registerPluginApiRoute(CONTACT_EMAIL_HISTORY_ROUTE, contactEmailHistoryHandler)\n // One email to one person from their record (AGL-2615): the recipient is\n // read off the record, the daily cap and both suppression lists are\n // judged, and the message leaves on the site's sending identity.\n registerPluginApiRoute(CRM_API_ROUTES.emailSend, crmEmailSendHandler)\n // Two records for one person become one (AGL-2625): the repoint of every\n // row naming the merged record and the transaction over both documents\n // are the server's, and the address index it writes is closed to clients.\n registerPluginApiRoute(CONTACTS_MERGE_ROUTE, contactsMergeHandler)\n // Files a person's privacy erasure for the daily job (AGL-2623);\n // workspace admins only.\n registerPluginApiRoute(CRM_ERASE_PERSON_ROUTE, crmErasePersonHandler)\n // One line in the organization's activity feed for an act the org-level\n // hub performed client-direct (AGL-2634): the feed is closed to clients,\n // so the bulk bars' lines come through here.\n registerPluginApiRoute(CRM_ORG_ACTIVITY_ROUTE, crmOrgActivityHandler)\n // A recipe's action written into one site's automations from the org\n // hub (AGL-2639), stamped and deduplicated by the server, and the stamps\n // read back per site so the hub can say which sites carry which recipe.\n registerPluginApiRoute(CRM_RECIPE_INSTALL_ROUTE, crmRecipeInstallHandler)\n registerPluginApiRoute(CRM_RECIPE_STATUS_ROUTE, crmRecipeStatusHandler)\n // The workspace's email capture address (AGL-2657): the token is minted\n // and rotated here, behind the CRM's own gate, and the org document that\n // carries it is closed to every client.\n registerPluginApiRoute(CRM_INBOUND_ADDRESS_ROUTE, crmInboundAddressHandler)\n registerPluginApiRoute(\n CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE,\n crmEmailTemplateDuplicateHandler,\n )\n // The facts of a contact, company, deal or lead, and an import's field\n // catalog, for another plugin in this process (AGL-2917): read on the core's\n // record-facts seam under the rules every CRM door applies, and written\n // nowhere.\n registerCrmRecordFactsReaders()\n // The CRM as the workspace's record system on the core's record-timeline\n // seam (AGL-2981): another plugin files an email, a reply or a task on a\n // record through it, under the CRM's own scope, dedupe and ceiling.\n registerCrmRecordTimelineWriter()\n // And on the record email-state seam (AGL-3245): a sender that filed a\n // verdict on an address — a bounce, a complaint, an unsubscribe, a\n // do-not-contact mark — stamps the lead and the contact through it.\n registerCrmRecordEmailStateWriter()\n}\n"],"names":["CONTACT_ERASED_MESSAGE","contactFacetPath","CRM_COLLECTIONS","crmReadTokens","isContactLifecycleStage","isOrgWideMember","normalizeAddress","normalizeContactEmail","normalizePhone","readContactFacet","registerPluginApiRoute","visibleToHost","consentGroupForSite","firebaseAdmin","getOrgForHost","logHostActivity","memberHasOrgPermission","orgDataCollectionForHost","resolveOrgMembership","isRefusedIdToken","captureHostContact","emitHostEvent","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_LEAD_SOURCE_VALUES_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","crmLeadSourceValuesHandler","registerCrmRecordEmailStateWriter","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmCompaniesImportHandler","crmContactsImportHandler","crmDealsImportHandler","crmLeadsImportHandler","crmTasksImportHandler","crmDealStageHandler","crmEmailSendHandler","leadConvertHandler","leadCreateHandler","CONTACT_EMAIL_HISTORY_ROUTE","contactEmailHistoryHandler","CONTACTS_MERGE_ROUTE","contactsMergeHandler","CRM_ERASE_PERSON_ROUTE","crmErasePersonHandler","CRM_ORG_ACTIVITY_ROUTE","crmOrgActivityHandler","CRM_INBOUND_ADDRESS_ROUTE","crmInboundAddressHandler","crmCompanyDeleteHandler","CONTACT_PHONE_REFUSAL","normalizeTags","typed","crmContactUpdateHandler","CRM_EMAIL_TEMPLATE_DUPLICATE_ROUTE","crmEmailTemplateDuplicateHandler","registerCrmRecordFactsReaders","crmSuiteRefusal","CRM_RECIPE_INSTALL_ROUTE","CRM_RECIPE_STATUS_ROUTE","crmRecipeInstallHandler","crmRecipeStatusHandler","crmPingHandler","req","res","method","setHeader","status","json","error","ok","plugin","authorizeSiteEditor","hostId","hostSnapshot","authorization","String","headers","idToken","startsWith","slice","length","undefined","uid","app","auth","verifyIdToken","console","firestore","collection","doc","get","exists","memberRole","contactStageHandler","body","trim","contactId","requested","lifecycleStage","clearing","caller","facet","owner","suite","org","contactsRef","snapshot","group","data","groupId","previousStage","changed","ref","update","delete","updatedAt","serverTimestamp","email","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","disclosedConsentGroup","decoded","resolved","isStaff","member","membership","orgId","reaches","Boolean","hostAccess","allowed","readable","Set","companySnapshot","companies","tokens","visible","Array","isArray","some","token","has","result","source","interaction","summary","refused","reason","contacts","arrayUnion","created","type","id","registerCrmConsoleApi","ping","contactStage","contactUpdate","companyDelete","save","complete","emailSend"],"mappings":"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"}