@aglyn/plugins-crm 1.0.0-beta.204 → 1.0.0-beta.206

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/plugins-crm",
3
- "version": "1.0.0-beta.204",
3
+ "version": "1.0.0-beta.206",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,18 +25,18 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.204",
29
- "@aglyn/shared-data-enums": "1.0.0-beta.204",
30
- "@aglyn/shared-data-mdi": "1.0.0-beta.204",
31
- "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.204",
32
- "@aglyn/shared-ui-jsx": "1.0.0-beta.204",
33
- "@aglyn/shared-ui-next": "1.0.0-beta.204",
34
- "@aglyn/shared-ui-snackstack": "1.0.0-beta.204",
35
- "@aglyn/shared-util-email": "1.0.0-beta.204",
36
- "@aglyn/shared-util-http": "1.0.0-beta.204",
37
- "@aglyn/tenant-data-admin": "1.0.0-beta.204",
38
- "@aglyn/tenant-feature-instance": "1.0.0-beta.204",
39
- "@aglyn/tenant-runtime": "1.0.0-beta.204",
28
+ "@aglyn/aglyn": "1.0.0-beta.206",
29
+ "@aglyn/shared-data-enums": "1.0.0-beta.206",
30
+ "@aglyn/shared-data-mdi": "1.0.0-beta.206",
31
+ "@aglyn/shared-ui-email-campaigns": "1.0.0-beta.206",
32
+ "@aglyn/shared-ui-jsx": "1.0.0-beta.206",
33
+ "@aglyn/shared-ui-next": "1.0.0-beta.206",
34
+ "@aglyn/shared-ui-snackstack": "1.0.0-beta.206",
35
+ "@aglyn/shared-util-email": "1.0.0-beta.206",
36
+ "@aglyn/shared-util-http": "1.0.0-beta.206",
37
+ "@aglyn/tenant-data-admin": "1.0.0-beta.206",
38
+ "@aglyn/tenant-feature-instance": "1.0.0-beta.206",
39
+ "@aglyn/tenant-runtime": "1.0.0-beta.206",
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/utilities": "^3.2.2",
42
42
  "@swc/helpers": "0.5.23",
@@ -18,6 +18,7 @@ import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
18
18
  import { formLeadSource } from "@aglyn/aglyn/app-utils/forms";
19
19
  import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
20
20
  import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
21
+ import { contactCaptureActor } from "@aglyn/tenant-runtime/capture-actor";
21
22
  import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
22
23
  import { addHostLeadOutcome, findContactByEmail, firebaseAdmin, orgDataCollectionForHost, readLeadForHost } from "@aglyn/tenant-data-admin";
23
24
  import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
@@ -167,13 +168,16 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
167
168
  };
168
169
  }
169
170
  if (created) {
171
+ const cause = contactCaptureActor(request.interaction.source, email, request.actor);
170
172
  await emitHostEvent(request.hostId, 'lead', _extends({
171
173
  email,
172
174
  source,
173
175
  leadId: key
174
176
  }, request.identity.name ? {
175
177
  name: request.identity.name
176
- } : {}));
178
+ } : {}), cause ? {
179
+ actor: cause
180
+ } : {});
177
181
  }
178
182
  return {
179
183
  ok: true,
@@ -186,7 +190,9 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
186
190
  /** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
187
191
  try {
188
192
  var _request_tags, _request_campaignIds;
189
- const verdict = await captureHostContact(_extends({
193
+ const verdict = await captureHostContact(_extends({}, request.actor ? {
194
+ actor: request.actor
195
+ } : {}, {
190
196
  hostId: request.hostId,
191
197
  email: request.identity.email
192
198
  }, request.identity.name ? {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n const cause = contactCaptureActor(\n request.interaction.source,\n email,\n request.actor,\n )\n await emitHostEvent(\n request.hostId,\n 'lead',\n {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n },\n cause ? { actor: cause } : {},\n )\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n ...(request.actor ? { actor: request.actor } : {}),\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","contactCaptureActor","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","stored","cause","actor","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
@@ -225,6 +225,11 @@ import { ownerDirectory, readImportRows, resolveImportContext } from "./import-c
225
225
  name: row.name
226
226
  } : {}, {
227
227
  source: 'import',
228
+ actor: {
229
+ kind: 'member',
230
+ uid: context.uid,
231
+ email: context.email
232
+ },
228
233
  interaction: {
229
234
  summary: CONTACT_IMPORT_INTERACTION_SUMMARY
230
235
  },
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.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 * `POST /api/crm/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n // The addresses' mail servers (AGL-3328), asked while the rows are\n // written — see the leads import. The check `upsertHostContact` queues\n // stamps the records; this is the count the result states.\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n imported.push(row.email)\n if (verdict.created) created += 1\n else merged += 1\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","crmNewRecordListFields","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","mailServers","imported","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","noMailServer","result","received","sort","a","b","companiesCreated","undefined","console"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contacts-import.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 * `POST /api/crm/contacts-import` — one chunk of a contact file, written\n * (AGL-2602).\n *\n * The browser has already read the file and applied the operator's column\n * mapping; what arrives here is up to {@link CONTACT_IMPORT_CHUNK_SIZE} raw\n * rows in the field vocabulary `crm-import.ts` defines. This route judges\n * each one and writes it, and it is the ONLY place either happens: the\n * drawer shows a preview from the same mapper but stores nothing, so a\n * stale tab cannot store something the server would have refused.\n *\n * ## Every row goes through the door every capture goes through\n *\n * `captureHostContact` — the runtime's wrapper over `upsertHostContact`,\n * the one writer of the contacts collection for every door that is not the\n * v1 API: forms, checkout, bookings, the newsletter box — is the writer\n * here too, so a row this file creates raises `contactCreated` like any\n * other capture (AGL-2605). That is what makes an import\n * dedupe on the address the way a second form submission does, land in the\n * capturing group's facet the way a form capture does, record consent\n * against the capturing site, and stop at a hard band where a form\n * capture stops. A bulk path with its own `add()` would be the fastest way\n * to grow an unscoped, unbanded, unconsented copy of the address book.\n *\n * What the door did not know how to write until now — a phone, a title, a\n * company, an owner, a stage, custom values — it now takes as `facet`, and\n * what it did not say until now — created or merged or refused — it now\n * returns as a verdict. Both changes are the door's, so the v1 API and the\n * manual add can reach them next.\n *\n * ## Two lookups paid once per request, not once per row\n *\n * An owner is named by email in the file and stored by uid on the record,\n * so the org's roster is read once and every row resolves against it. A\n * company is named by name and stored by id, so each distinct name is\n * looked up once, created once when missing, and remembered for the rest\n * of the request — two hundred rows at one company is one read and at most\n * one write, not two hundred of each.\n *\n * ## Who may call it\n *\n * `resolveImportContext` (`import-context.ts`) — the two gates every CSV\n * import route asks, shared with the companies import so one permission\n * has one spelling.\n */\n\nimport {\n type ContactFieldDefinition,\n CRM_COLLECTIONS,\n crmNewRecordListFields,\n nameSearchFields,\n type PluginApiHandler,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n CONTACT_IMPORT_CHUNK_SIZE,\n CONTACT_IMPORT_MAX_BODY_BYTES,\n type ContactImportChunkResult,\n type ContactImportRawRow,\n type ContactImportRow,\n type ContactImportSkippedRow,\n normalizeContactImportRow,\n} from '../model/crm-import'\nimport { crmRecordsQuotaForOrg, firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport { captureHostContact } from '@aglyn/tenant-runtime'\n// The leaf, so a spec that stands a partial barrel in still reaches it.\nimport {\n countUndeliverableEmails,\n findUndeliverableEmails,\n IMPORT_MAIL_CHECK_GRACE_MS,\n settleWithin,\n} from '@aglyn/tenant-data-admin/server/capture-email-check'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n type ImportContext,\n ownerDirectory,\n readImportRows,\n resolveImportContext,\n} from './import-context'\n\n/** The one sentence every imported contact's timeline opens with. */\nexport const CONTACT_IMPORT_INTERACTION_SUMMARY = 'Imported from CSV'\n\n/**\n * The holder's live custom-field definitions.\n *\n * Read in full and filtered in memory: a holder has a handful of fields,\n * and a `where` on `retiredAt` would need an index for a collection that\n * fits in one page. Definitions the caller's scope cannot see are left out\n * for the same reason a value under an undefined key is — a value written\n * under a field the holder's own form will never show is a value nobody\n * can edit.\n */\nasync function loadFieldDefinitions(\n orgId: string,\n readTokens: readonly string[],\n): Promise<ContactFieldDefinition[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(200)\n .get()\n return snapshot.docs\n .map((doc) => doc.data() as ContactFieldDefinition)\n .filter(\n (field) =>\n !!field.key &&\n !field.retiredAt &&\n visibleToTokens(field.visibleTo, readTokens),\n )\n}\n\n/** A company an import row named, as the CRM stores it. */\ninterface ImportCompany {\n id: string\n name: string\n}\n\n/**\n * The company a name refers to in this scope, created when there is none.\n *\n * Looked up by `nameLower`, the search twin `nameSearchFields` writes on\n * every company, and narrowed in memory to the ones the caller's scope can\n * see — two clients of one agency may each have an \"Acme\" and must each get\n * their own. Created with the same scope stamp every CRM creator uses, so\n * the company lands exactly where a contact captured on this site would.\n */\nasync function resolveCompany(\n context: Extract<ImportContext, { ok: true }>,\n name: string,\n cache: Map<string, ImportCompany>,\n tally: { created: number },\n): Promise<ImportCompany | null> {\n const fields = nameSearchFields(name)\n const cached = cache.get(fields.nameLower)\n if (cached) return cached\n const orgRef = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(context.orgId)\n const companies = orgRef.collection(CRM_COLLECTIONS.companies)\n const matches = await companies\n .where('nameLower', '==', fields.nameLower)\n .limit(10)\n .get()\n const seen = matches.docs.find((doc) =>\n visibleToTokens(doc.get('visibleTo'), context.readTokens),\n )\n if (seen) {\n // The name as the record spells it, not as this row typed it, so the\n // contact's facet reads the same as the company page.\n const found = { id: seen.id, name: String(seen.get('name') ?? name) }\n cache.set(fields.nameLower, found)\n return found\n }\n /*\n * THE RECORDS BAND (AGL-2611). A company is a record of the same band\n * the contact behind this row will meet at its own door, so on an org\n * at a full hard band the row gets no company rather than a company it\n * was not allowed to hold. Nothing is reported here: the contact is\n * refused `audience-band` a few lines on and the row is listed skipped,\n * which is the one message the operator needs. A plan with an overage\n * rate never reaches this branch — a rate is what makes the band meter\n * instead of refuse.\n */\n const room = await crmRecordsQuotaForOrg(context.org as never, orgRef)\n if (!room.allowed) return null\n const created = await companies.add({\n ...fields,\n hostId: context.hostId,\n visibleTo: context.scopeTokens,\n createdByUid: context.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', { ...fields, visibleTo: context.scopeTokens }),\n })\n tally.created += 1\n const made = { id: created.id, name }\n cache.set(fields.nameLower, made)\n return made\n}\n\n/**\n * `POST crm/contacts-import` — `{ hostId, rows }` → a {@link ContactImportChunkResult}.\n *\n * Rows are written one after another rather than in parallel, because the\n * door's create path counts the collection before it adds: two hundred\n * creates racing one another would each count the same audience and each\n * pass a band the last of them should have hit.\n */\nexport const crmContactsImportHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n const read = readImportRows<ContactImportRawRow>(req, {\n maxBodyBytes: CONTACT_IMPORT_MAX_BODY_BYTES,\n chunkSize: CONTACT_IMPORT_CHUNK_SIZE,\n })\n if ('error' in read) return res.status(read.status).json({ error: read.error })\n try {\n const context = await resolveImportContext(req)\n if (context.ok === false) return res.status(context.status).json(context.body)\n\n const fields = await loadFieldDefinitions(context.orgId, context.readTokens)\n const skipped: ContactImportSkippedRow[] = []\n const dropped: Record<string, number> = {}\n const normalized: { index: number; row: ContactImportRow }[] = []\n /*\n * A duplicate WITHIN the request is skipped rather than merged: the\n * second row would merge onto the first's record and the count would\n * read one created and one merged for one person in one file, which is\n * a number the operator cannot reconcile against the rows they sent.\n * Across requests the door's own dedupe answers, and reports a merge.\n */\n const seen = new Set<string>()\n read.rows.forEach((raw, index) => {\n const verdict = normalizeContactImportRow(raw, fields)\n if (verdict.ok === false) {\n skipped.push({ index, email: verdict.input, reason: verdict.reason })\n return\n }\n if (seen.has(verdict.row.email)) {\n skipped.push({ index, email: verdict.row.email, reason: 'duplicate' })\n return\n }\n seen.add(verdict.row.email)\n for (const entry of verdict.row.dropped) {\n dropped[entry.field] = (dropped[entry.field] ?? 0) + 1\n }\n normalized.push({ index, row: verdict.row })\n })\n\n // The addresses' mail servers (AGL-3328), asked while the rows are\n // written — see the leads import. The check `upsertHostContact` queues\n // stamps the records; this is the count the result states.\n const mailServers = findUndeliverableEmails(normalized.map((entry) => entry.row.email))\n const imported: string[] = []\n const owners = await ownerDirectory(\n context.orgId,\n normalized.map((entry) => entry.row),\n )\n const ownersUnresolved = new Set<string>()\n const companies = new Map<string, ImportCompany>()\n const companyTally = { created: 0 }\n let created = 0\n let merged = 0\n\n for (const { index, row } of normalized) {\n let ownerUid: string | undefined\n if (row.ownerEmail) {\n ownerUid = owners.get(row.ownerEmail)\n if (!ownerUid) ownersUnresolved.add(row.ownerEmail)\n }\n const company = row.companyName\n ? await resolveCompany(context, row.companyName, companies, companyTally)\n : null\n const verdict = await captureHostContact({\n hostId: context.hostId,\n email: row.email,\n ...(row.name ? { name: row.name } : {}),\n source: 'import',\n actor: { kind: 'member', uid: context.uid, email: context.email },\n interaction: { summary: CONTACT_IMPORT_INTERACTION_SUMMARY },\n marketingConsent: row.marketingConsent,\n tags: row.tags,\n facet: {\n ...(row.phone ? { phone: row.phone } : {}),\n ...(row.jobTitle ? { jobTitle: row.jobTitle } : {}),\n // The link AND the name: the merge fields read the facet's\n // `companyName`, and a link written alone renders as nothing.\n ...(company ? { companyId: company.id, companyName: company.name } : {}),\n ...(row.address ? { address: row.address } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n ...(row.lifecycleStage ? { lifecycleStage: row.lifecycleStage } : {}),\n ...(Object.keys(row.custom).length ? { custom: row.custom } : {}),\n },\n // An import is the merchant's own act and files nobody under a\n // campaign; the picker on the profile is where that happens.\n campaignIds: [],\n })\n if ('refused' in verdict) {\n skipped.push({\n index,\n email: row.email,\n reason:\n verdict.refused === 'band'\n ? 'audience-band'\n : verdict.refused === 'invalid-email'\n ? 'invalid-email'\n : verdict.refused === 'erased'\n ? 'erased'\n : 'write-failed',\n })\n continue\n }\n imported.push(row.email)\n if (verdict.created) created += 1\n else merged += 1\n }\n const noMailServer = countUndeliverableEmails(\n imported,\n await settleWithin(mailServers, IMPORT_MAIL_CHECK_GRACE_MS),\n )\n\n const result: ContactImportChunkResult = {\n received: read.rows.length,\n created,\n merged,\n skipped: skipped.sort((a, b) => a.index - b.index),\n dropped,\n companiesCreated: companyTally.created,\n ownersUnresolved: [...ownersUnresolved],\n ...(noMailServer !== undefined ? { noMailServer } : {}),\n }\n return res.status(200).json(result)\n } catch (error) {\n console.error('crm/contacts-import failed', error)\n return res.status(500).json({ error: 'The import could not continue.' })\n }\n}\n"],"names":["CRM_COLLECTIONS","crmNewRecordListFields","nameSearchFields","visibleToTokens","CONTACT_IMPORT_CHUNK_SIZE","CONTACT_IMPORT_MAX_BODY_BYTES","normalizeContactImportRow","crmRecordsQuotaForOrg","firebaseAdmin","captureHostContact","countUndeliverableEmails","findUndeliverableEmails","IMPORT_MAIL_CHECK_GRACE_MS","settleWithin","FieldValue","ownerDirectory","readImportRows","resolveImportContext","CONTACT_IMPORT_INTERACTION_SUMMARY","loadFieldDefinitions","orgId","readTokens","snapshot","app","firestore","collection","doc","contactFields","limit","get","docs","map","data","filter","field","key","retiredAt","visibleTo","resolveCompany","context","name","cache","tally","fields","cached","nameLower","orgRef","companies","matches","where","seen","find","found","id","String","set","room","org","allowed","created","add","hostId","scopeTokens","createdByUid","uid","createdAt","serverTimestamp","updatedAt","made","crmContactsImportHandler","req","res","method","setHeader","status","json","error","read","maxBodyBytes","chunkSize","ok","body","skipped","dropped","normalized","Set","rows","forEach","raw","index","verdict","push","email","input","reason","has","row","entry","mailServers","imported","owners","ownersUnresolved","Map","companyTally","merged","ownerUid","ownerEmail","company","companyName","source","actor","kind","interaction","summary","marketingConsent","tags","facet","phone","jobTitle","companyId","address","lifecycleStage","Object","keys","custom","length","campaignIds","refused","noMailServer","result","received","sort","a","b","companiesCreated","undefined","console"],"mappings":"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"}
@@ -41,6 +41,8 @@ import { type PluginApiHandler } from '@aglyn/aglyn/server';
41
41
  export type ImportContext = {
42
42
  ok: true;
43
43
  uid: string;
44
+ /** The importer's address, for who set off the runs it causes (AGL-3376). */
45
+ email: string | null;
44
46
  hostId: string;
45
47
  orgId: string;
46
48
  org: Record<string, unknown>;
@@ -119,6 +119,7 @@ import { crmSuiteRefusal } from "./suite-gate.js";
119
119
  return {
120
120
  ok: true,
121
121
  uid: decoded.uid,
122
+ email: typeof decoded.email === 'string' ? decoded.email : null,
122
123
  hostId,
123
124
  orgId,
124
125
  org,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/import-context.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 * WHAT EVERY CSV IMPORT ROUTE ASKS BEFORE IT READS A ROW (AGL-2602, shared\n * by the companies import since AGL-2621).\n *\n * Who is asking and on whose behalf, whether the request is the size a\n * chunk is allowed to be, and — when a row names an owner — which member\n * of the org that address is. The contacts route settled each answer; the\n * companies route needs the same three and must not spell them\n * differently, because two gates for one permission is how one of them\n * comes to be wider.\n *\n * ## Who may call an import\n *\n * The same two gates the rules put on every CRM collection: a site role\n * on the host the request names, AND the organization's `data.manage`\n * permission resolved through the member's custom role and overrides. The\n * Admin SDK evaluates no rules, so this is the enforcement rather than an\n * echo of it — and `data.manage` rather than the raw role because an owner\n * who unticked \"Manage data\" on a custom role meant it. A staff token\n * passes both gates the way it does on every console route: support\n * importing a customer's file for them is the support act this exists for.\n */\n\nimport {\n consentGroupScope,\n crmScopeTokens,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n listOrgMembers,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** Everything an import needs about who is asking, or the refusal to send back. */\nexport type ImportContext =\n | {\n ok: true\n uid: string\n hostId: string\n orgId: string\n org: Record<string, unknown>\n /**\n * The stamp a record CREATED by this import carries — the org, when\n * the org widened its default, and otherwise the capturing group.\n */\n scopeTokens: string[]\n /**\n * The tokens a record must overlap to be SEEN from this site: the\n * group's own, plus `org`, because an org-wide record is visible to\n * every site in the account. The reader's set is wider than the\n * creator's stamp, exactly as it is on the collection listeners.\n */\n readTokens: string[]\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\nexport type ImportRequest = Parameters<PluginApiHandler>[0]\n\n/** Who is asking, and on whose behalf. */\nexport async function resolveImportContext(req: ImportRequest): Promise<ImportContext> {\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) {\n return { ok: false, status: 400, body: { error: 'Missing hostId' } }\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 return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n const orgId = String(resolved?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no CRM.' },\n }\n }\n const org = (resolved?.org ?? {}) as Record<string, unknown>\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n if (\n !staff &&\n (!member ||\n (member as { orgSuspended?: boolean }).orgSuspended === true ||\n !(await memberHasOrgPermission(orgId, member, 'data.manage')))\n ) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Your organization role does not allow managing contacts and ' +\n 'companies, so it cannot import them.',\n },\n }\n }\n // Every file this context serves — contacts, companies, deals, tasks and\n // leads — is the CRM's to import, whoever is asking (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Importing from a CSV file')\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n const group = await consentGroupForSite(hostId)\n return {\n ok: true,\n uid: decoded.uid,\n hostId,\n orgId,\n org,\n scopeTokens: crmScopeTokens(org, group),\n readTokens: [ORG_SCOPE_TOKEN, ...consentGroupScope(group)],\n }\n}\n\n/** The rows a request may carry, or the refusal to send back. */\nexport function readImportRows<R>(\n req: ImportRequest,\n limits: { maxBodyBytes: number; chunkSize: number },\n): { rows: R[] } | { status: number; error: string } {\n if ((req.rawBody?.length ?? 0) > limits.maxBodyBytes) {\n return {\n status: 413,\n error: 'That request is too large. Import the file in smaller pieces.',\n }\n }\n const rows = req.body?.rows\n if (!Array.isArray(rows) || rows.length === 0) {\n return { status: 400, error: 'No rows to import.' }\n }\n if (rows.length > limits.chunkSize) {\n return {\n status: 400,\n error: `At most ${limits.chunkSize} rows per request.`,\n }\n }\n if (!rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {\n return { status: 400, error: 'Every row must be an object.' }\n }\n return { rows: rows as R[] }\n}\n\n/**\n * The org's members by address, read only when a row names an owner.\n *\n * A file with no owner column costs no roster read; a file with one costs\n * exactly one, however many rows it has.\n */\nexport async function ownerDirectory(\n orgId: string,\n rows: readonly { ownerEmail?: string }[],\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!rows.some((row) => row.ownerEmail)) return directory\n const members = await listOrgMembers(orgId)\n for (const member of members) {\n const email = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (email && member.$id) directory.set(email, String(member.$id))\n }\n return directory\n}\n"],"names":["consentGroupScope","crmScopeTokens","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","getOrgForHost","listOrgMembers","memberHasOrgPermission","resolveOrgMembership","crmSuiteRefusal","resolveImportContext","req","hostSnapshot","hostId","String","body","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","staff","firestore","collection","doc","get","exists","memberRole","uid","resolved","catch","orgId","org","membership","member","orgSuspended","suite","group","scopeTokens","readTokens","readImportRows","limits","rawBody","maxBodyBytes","rows","Array","isArray","chunkSize","every","row","ownerDirectory","directory","Map","some","ownerEmail","members","email","trim","toLowerCase","$id","set"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/import-context.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 * WHAT EVERY CSV IMPORT ROUTE ASKS BEFORE IT READS A ROW (AGL-2602, shared\n * by the companies import since AGL-2621).\n *\n * Who is asking and on whose behalf, whether the request is the size a\n * chunk is allowed to be, and — when a row names an owner — which member\n * of the org that address is. The contacts route settled each answer; the\n * companies route needs the same three and must not spell them\n * differently, because two gates for one permission is how one of them\n * comes to be wider.\n *\n * ## Who may call an import\n *\n * The same two gates the rules put on every CRM collection: a site role\n * on the host the request names, AND the organization's `data.manage`\n * permission resolved through the member's custom role and overrides. The\n * Admin SDK evaluates no rules, so this is the enforcement rather than an\n * echo of it — and `data.manage` rather than the raw role because an owner\n * who unticked \"Manage data\" on a custom role meant it. A staff token\n * passes both gates the way it does on every console route: support\n * importing a customer's file for them is the support act this exists for.\n */\n\nimport {\n consentGroupScope,\n crmScopeTokens,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n firebaseAdmin,\n getOrgForHost,\n listOrgMembers,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/** Everything an import needs about who is asking, or the refusal to send back. */\nexport type ImportContext =\n | {\n ok: true\n uid: string\n /** The importer's address, for who set off the runs it causes (AGL-3376). */\n email: string | null\n hostId: string\n orgId: string\n org: Record<string, unknown>\n /**\n * The stamp a record CREATED by this import carries — the org, when\n * the org widened its default, and otherwise the capturing group.\n */\n scopeTokens: string[]\n /**\n * The tokens a record must overlap to be SEEN from this site: the\n * group's own, plus `org`, because an org-wide record is visible to\n * every site in the account. The reader's set is wider than the\n * creator's stamp, exactly as it is on the collection listeners.\n */\n readTokens: string[]\n }\n | { ok: false; status: number; body: Record<string, unknown> }\n\nexport type ImportRequest = Parameters<PluginApiHandler>[0]\n\n/** Who is asking, and on whose behalf. */\nexport async function resolveImportContext(req: ImportRequest): Promise<ImportContext> {\n const hostId = String(req.body?.hostId ?? '')\n if (!hostId) {\n return { ok: false, status: 400, body: { error: 'Missing hostId' } }\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 return { ok: false, status: 401, body: { error: 'Unauthenticated' } }\n }\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const firestore = firebaseAdmin.app().firestore()\n const hostSnapshot = await firestore.collection('hosts').doc(hostId).get()\n if (!hostSnapshot.exists) {\n return { ok: false, status: 404, body: { error: 'Unknown site' } }\n }\n const memberRole = (hostSnapshot.get('memberRoles') ?? {})[decoded.uid]\n if (!staff && memberRole !== 'admin' && memberRole !== 'editor') {\n return {\n ok: false,\n status: 403,\n body: { error: 'Not a site admin or editor' },\n }\n }\n const resolved = await getOrgForHost(hostId).catch(() => null)\n const orgId = String(resolved?.orgId ?? '')\n if (!orgId) {\n return {\n ok: false,\n status: 404,\n body: { error: 'This site has no organization, so it has no CRM.' },\n }\n }\n const org = (resolved?.org ?? {}) as Record<string, unknown>\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = membership?.member\n if (\n !staff &&\n (!member ||\n (member as { orgSuspended?: boolean }).orgSuspended === true ||\n !(await memberHasOrgPermission(orgId, member, 'data.manage')))\n ) {\n return {\n ok: false,\n status: 403,\n body: {\n error:\n 'Your organization role does not allow managing contacts and ' +\n 'companies, so it cannot import them.',\n },\n }\n }\n // Every file this context serves — contacts, companies, deals, tasks and\n // leads — is the CRM's to import, whoever is asking (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Importing from a CSV file')\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n const group = await consentGroupForSite(hostId)\n return {\n ok: true,\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n hostId,\n orgId,\n org,\n scopeTokens: crmScopeTokens(org, group),\n readTokens: [ORG_SCOPE_TOKEN, ...consentGroupScope(group)],\n }\n}\n\n/** The rows a request may carry, or the refusal to send back. */\nexport function readImportRows<R>(\n req: ImportRequest,\n limits: { maxBodyBytes: number; chunkSize: number },\n): { rows: R[] } | { status: number; error: string } {\n if ((req.rawBody?.length ?? 0) > limits.maxBodyBytes) {\n return {\n status: 413,\n error: 'That request is too large. Import the file in smaller pieces.',\n }\n }\n const rows = req.body?.rows\n if (!Array.isArray(rows) || rows.length === 0) {\n return { status: 400, error: 'No rows to import.' }\n }\n if (rows.length > limits.chunkSize) {\n return {\n status: 400,\n error: `At most ${limits.chunkSize} rows per request.`,\n }\n }\n if (!rows.every((row) => row && typeof row === 'object' && !Array.isArray(row))) {\n return { status: 400, error: 'Every row must be an object.' }\n }\n return { rows: rows as R[] }\n}\n\n/**\n * The org's members by address, read only when a row names an owner.\n *\n * A file with no owner column costs no roster read; a file with one costs\n * exactly one, however many rows it has.\n */\nexport async function ownerDirectory(\n orgId: string,\n rows: readonly { ownerEmail?: string }[],\n): Promise<Map<string, string>> {\n const directory = new Map<string, string>()\n if (!rows.some((row) => row.ownerEmail)) return directory\n const members = await listOrgMembers(orgId)\n for (const member of members) {\n const email = String(member.email ?? '')\n .trim()\n .toLowerCase()\n if (email && member.$id) directory.set(email, String(member.$id))\n }\n return directory\n}\n"],"names":["consentGroupScope","crmScopeTokens","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","getOrgForHost","listOrgMembers","memberHasOrgPermission","resolveOrgMembership","crmSuiteRefusal","resolveImportContext","req","hostSnapshot","hostId","String","body","ok","status","error","authorization","headers","idToken","startsWith","slice","length","undefined","decoded","app","auth","verifyIdToken","staff","firestore","collection","doc","get","exists","memberRole","uid","resolved","catch","orgId","org","membership","member","orgSuspended","suite","group","email","scopeTokens","readTokens","readImportRows","limits","rawBody","maxBodyBytes","rows","Array","isArray","chunkSize","every","row","ownerDirectory","directory","Map","some","ownerEmail","members","trim","toLowerCase","$id","set"],"mappings":"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"}
@@ -70,6 +70,8 @@ export type Refusal = {
70
70
  export interface Writer {
71
71
  ok: true;
72
72
  uid: string;
73
+ /** The caller's address, for who set off the runs a write causes (AGL-3376). */
74
+ email: string | null;
73
75
  staff: boolean;
74
76
  orgId: string;
75
77
  org: Record<string, unknown>;
@@ -44,6 +44,7 @@ const ORG_REFUSAL = 'Editing tasks at the organization level requires the "Manag
44
44
  var _req_headers_authorization, _ref;
45
45
  const suiteAct = options.suiteAct === undefined ? TASKS_ACT : options.suiteAct;
46
46
  if (scope.level === 'org') {
47
+ var _caller_email;
47
48
  const caller = await authorizeOrgCaller(req, scope.orgId, {
48
49
  needs: 'data.manage',
49
50
  refusal: ORG_REFUSAL
@@ -59,6 +60,7 @@ const ORG_REFUSAL = 'Editing tasks at the organization level requires the "Manag
59
60
  return {
60
61
  ok: true,
61
62
  uid: caller.uid,
63
+ email: (_caller_email = caller.email) != null ? _caller_email : null,
62
64
  staff: caller.staff,
63
65
  orgId: caller.orgId,
64
66
  org: caller.org,
@@ -104,6 +106,7 @@ const ORG_REFUSAL = 'Editing tasks at the organization level requires the "Manag
104
106
  return {
105
107
  ok: true,
106
108
  uid: decoded.uid,
109
+ email: typeof decoded.email === 'string' ? decoded.email : null,
107
110
  staff,
108
111
  orgId,
109
112
  org: org != null ? org : {},
@@ -589,6 +592,12 @@ const tasksCollection = (orgId)=>firebaseAdmin.app().firestore().collection('org
589
592
  companyId: String((_task_companyId = task.companyId) != null ? _task_companyId : ''),
590
593
  dealId: String((_task_dealId = task.dealId) != null ? _task_dealId : ''),
591
594
  taskHostId: String((_task_hostId1 = task.hostId) != null ? _task_hostId1 : '')
595
+ }, {
596
+ actor: {
597
+ kind: 'member',
598
+ uid: writer.uid,
599
+ email: writer.email
600
+ }
592
601
  });
593
602
  }
594
603
  return {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgMember,\n consentGroupForHost,\n CRM_COLLECTIONS,\n type CrmTask,\n crmScopeTokens,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n crmTaskReminderPending,\n isOrgWideMember,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n} from '@aglyn/aglyn/server'\nimport {\n crmNextActivityLinksOf,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n notifyUsers,\n recomputeCrmNextTaskAt,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_TASK_BATCH_MAX,\n type CrmTaskCompleteOutcome,\n type CrmTaskCompleteResponse,\n type CrmTaskFields,\n type CrmTaskSaveOutcome,\n type CrmTaskSaveResponse,\n type CrmTasksCompleteResponse,\n type CrmTasksSaveResponse,\n readCrmTaskFields,\n} from '../model/task-routes'\nimport { CRM_ORG_TASK_SCOPE } from '../model/task-scope'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * The two things a task does that a browser must not do alone (AGL-2599).\n *\n * Every other task write is client-direct against the Firestore rules —\n * reopening, deleting, the list's own reads. These two are routes because\n * each has a side effect that lives outside the document:\n *\n * - SAVING a task assigned to somebody else NOTIFIES them, and a\n * notification is an Admin-SDK write into another person's inbox. Rather\n * than a client-direct write followed by a \"please notify\" call — two\n * operations that can disagree about whether the task exists — the route\n * persists the task itself and notifies from the same code path, so a\n * notification can never announce a task that failed to save. It also\n * stamps the scope, the provenance and the timestamps, which is one\n * creator computing `visibleTo` rather than two.\n *\n * - COMPLETING a task EMITS the `taskCompleted` host event, which is what a\n * workflow triggers on. A browser cannot run the workflow runner.\n *\n * Both authenticate the way the other console plugin routes do — the ID\n * token in the `Authorization` header — and then ask the same two questions\n * the rules ask of a `crmTasks` write: is this member allowed to write org\n * data (`owner`/`admin`/`editor`, and `data.manage` resolved through their\n * custom role), and can they reach THIS document's scope.\n *\n * ## The organization variant (AGL-2637)\n *\n * `{ orgId, … }` instead of a site: the org-level hub. The caller is\n * authorized by the ORG — an org-wide member holding `data.manage`, the\n * hub's own gate, never a site collaborator — and reaches every task the\n * org holds, so no scope check is made against the document. A NEW task\n * names the site it is filed from beside the org, or names none and is the\n * organization's own: `hostId: null`, the org scope token alone. Completing\n * emits on the task's OWN site, where its automations live; an\n * organization task has none and emits nothing, as an uncaptured deal's\n * stage change emits nothing.\n *\n * The org variant also takes a BATCH — `taskIds` to complete, `tasks` to\n * save — so the org hub's bulk bar makes one request per action rather\n * than one per task. Each task is answered on its own in `results`; the\n * request as a whole is refused only for what refuses every task alike.\n */\n\nexport type Refusal = {\n ok: false\n status: number\n body: { error: string; reason?: string; code?: string }\n}\n\n/** What the suite gate names when a task route refuses a plan without it. */\nconst TASKS_ACT = \"Working a record's tasks\"\n\nexport interface Writer {\n ok: true\n uid: string\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n /** The member document under a site; `null` at the org level, where the reach is the org's. */\n member: Partial<AglynOrgMember> | null\n level: CrmRouteScope['level']\n}\n\nexport const refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n body: { error },\n})\n\nconst ORG_REFUSAL =\n 'Editing tasks at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.'\n\nexport interface CrmWriterOptions {\n /**\n * The act a plan without the CRM is refused for, named the way\n * `crmSuiteRefusalMessage` takes it. `null` for a route that asks the plan\n * itself, once it knows what the request writes, so the refusal can name\n * that act — a contact update names the first field it carries. Tasks\n * when unsaid.\n */\n suiteAct?: string | null\n}\n\n/**\n * Who is asking, and whether they may write this CRM at all.\n *\n * Under a site: the org resolved from the site and the rules' own role and\n * permission questions. At the organization level: the org the body names,\n * for an org-wide member with the permission. The scope question — may they\n * reach THIS task — is asked by the caller against the document, because on\n * a create the document does not exist yet and its scope is what the route\n * is about to stamp.\n */\nexport async function authorizeCrmWriter(\n req: PluginApiRequest,\n scope: CrmRouteScope,\n options: CrmWriterOptions = {},\n): Promise<Writer | Refusal> {\n const suiteAct = options.suiteAct === undefined ? TASKS_ACT : options.suiteAct\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: ORG_REFUSAL,\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n // The plan after the person (AGL-2787): see `suite-gate.ts`.\n const suite = suiteAct ? crmSuiteRefusal(caller.org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: caller.uid,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org as Record<string, unknown>,\n member: null,\n level: 'org',\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const resolved = await getOrgForHost(scope.hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const role = String(member?.role ?? '')\n /*\n * The rules' `canWriteOrgData()` restated: the role list is what an\n * unstamped permission map falls through to, and the permission is the\n * only half a custom role's revocation can reach. A route that checked\n * one and not the other would open a door the rules keep shut.\n */\n const writerRole = role === 'owner' || role === 'admin' || role === 'editor'\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || !writerRole || suspended) {\n return refuse(403, 'Your organization role does not allow editing the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(\n 403,\n 'Your organization role does not allow editing the CRM.',\n )\n }\n }\n const suite = suiteAct ? crmSuiteRefusal(org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: decoded.uid,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n level: 'site',\n }\n}\n\n/**\n * The rules' `canReadScoped()`, for a document the route has in hand. An\n * org-level writer was admitted as org-wide, and an org-wide member reads\n * every row.\n */\nexport const canReach = (writer: Writer, visibleTo: unknown): boolean =>\n writer.staff ||\n writer.level === 'org' ||\n isOrgWideMember(writer.member) ||\n memberCanSee(writer.member, visibleTo as string[] | undefined)\n\n/**\n * The records the task names carry `nextTaskAtMs` (AGL-2661), recomputed\n * after every write here. Its own catch: the task IS saved by now, and a\n * denormalized figure that could not move is logged and left for the\n * Fields section's recompute, not reported as a task that failed to save.\n */\nasync function settleNextActivity(\n orgId: string,\n links: readonly (Partial<CrmTaskFields> | Record<string, unknown> | null | undefined)[],\n): Promise<void> {\n try {\n await recomputeCrmNextTaskAt(\n firebaseAdmin.app().firestore(),\n orgId,\n links.map((link) => crmNextActivityLinksOf(link as Record<string, unknown>)),\n )\n } catch (error) {\n console.error('[crm] next activity could not be recomputed', orgId, error)\n }\n}\n\n/**\n * The console path the assignee's notification opens: the record the task\n * is for, in the hub, or the tasks list when it is for nobody in particular.\n *\n * In the `/{hostDocId}/rest` shape every host notification uses — the\n * console rewrites it to `/{orgSlug}/hosts/{subdomain}/rest` when the link\n * is followed, which is why `hostId` and `orgId` travel on the document. An\n * organization task has no site to open under, so its link is the `/org`\n * shape the console rewrites onto the organization's own hub.\n */\nfunction assignmentLink(hostId: string | null, fields: CrmTaskFields): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (fields.contactId) return `${base}/contacts/${encodeURIComponent(fields.contactId)}`\n if (fields.dealId) return `${base}/deals/${encodeURIComponent(fields.dealId)}`\n if (fields.companyId) return `${base}/companies/${encodeURIComponent(fields.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * The editable fields as a Firestore write.\n *\n * The optional ids and the assignee are ABSENT on the document when unset,\n * never `null` — the record cards query `where('contactId', '==', id)` and\n * the \"My tasks\" view `where('assigneeUid', '==', uid)`, and neither is\n * helped by a null. So a create leaves an unset optional out, and an update\n * DELETES it: an assignee cleared in the drawer has to come off the\n * document, and an update that merely omitted the key would keep them.\n * `dueAtMs` is the one field written as `null` when unset, because every\n * view orders by it and a document missing the field drops out of an\n * `orderBy` entirely.\n */\nfunction storedFields(\n fields: CrmTaskFields,\n mode: 'create' | 'update',\n): Record<string, unknown> {\n const absent = mode === 'update' ? FieldValue.delete() : undefined\n const optional = (value: string | null) =>\n value ? { present: true, value } : { present: mode === 'update', value: absent }\n const out: Record<string, unknown> = {\n title: fields.title,\n kind: fields.kind,\n priority: fields.priority,\n dueAtMs: fields.dueAtMs,\n notes: fields.notes,\n }\n for (const [key, value] of [\n ['assigneeUid', fields.assigneeUid],\n ['contactId', fields.contactId],\n ['companyId', fields.companyId],\n ['dealId', fields.dealId],\n ] as const) {\n const slot = optional(value)\n if (slot.present) out[key] = slot.value\n }\n return out\n}\n\n/**\n * The reminder a save leaves (AGL-2659), as a Firestore write.\n *\n * `crmTaskReminderAfterEdit` decides which reminder the task ends up with;\n * this turns the decision into fields. Written as `null` when unset, like\n * `dueAtMs`, so the document says the reminder was decided. A reminder that\n * MOVED — to a new time, or to none — is a reminder not yet sent, so the\n * runner's mark comes off with it; one that stayed keeps its mark, which is\n * what stops a title correction from re-sending last week's reminder.\n */\nfunction reminderFields(\n fields: CrmTaskFields,\n existing: FirebaseFirestore.DocumentSnapshot | null,\n): Record<string, unknown> {\n const previous = existing\n ? {\n dueAtMs: (existing.get('dueAtMs') as number | null | undefined) ?? null,\n remindAtMs: (existing.get('remindAtMs') as number | null | undefined) ?? null,\n }\n : null\n const remindAtMs = crmTaskReminderAfterEdit({\n dueAtMs: fields.dueAtMs,\n remindAtMs: fields.remindAtMs,\n previous,\n })\n const out: Record<string, unknown> = { remindAtMs }\n if (previous && remindAtMs !== previous.remindAtMs) {\n out['reminderSentAtMs'] = FieldValue.delete()\n }\n return out\n}\n\nconst tasksCollection = (orgId: string) =>\n firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.tasks)\n\ntype TasksCollection = ReturnType<typeof tasksCollection>\n\n/**\n * Whether a uid names a member of the org, answered once per uid across a\n * batch: a bar reassigning forty tasks to one person asks the roster once.\n */\nfunction rosterOf(orgId: string) {\n const known = new Map<string, Promise<boolean>>()\n return (uid: string): Promise<boolean> => {\n let answer = known.get(uid)\n if (!answer) {\n answer = resolveOrgMembership(uid, orgId)\n .then((found) => Boolean(found?.member))\n .catch(() => false)\n known.set(uid, answer)\n }\n return answer\n }\n}\n\n/** Tell the assignee, when they are somebody new and somebody else. */\nasync function notifyAssignee(\n writer: Writer,\n fields: CrmTaskFields,\n previousAssignee: string | null,\n hostId: string | null,\n): Promise<boolean> {\n const assignee = fields.assigneeUid\n const notified = Boolean(\n assignee && assignee !== writer.uid && assignee !== previousAssignee,\n )\n if (!notified || !assignee) return false\n const due =\n typeof fields.dueAtMs === 'number'\n ? ` · due ${new Date(fields.dueAtMs).toLocaleString()}`\n : ''\n await notifyUsers([assignee], {\n type: 'content.taskAssigned',\n title: 'Task assigned to you',\n body: `${fields.title}${due}`,\n link: assignmentLink(hostId, fields),\n orgId: writer.orgId,\n ...(hostId ? { hostId } : {}),\n })\n return true\n}\n\n/**\n * Rewrite one existing task's editable fields, and tell a new assignee.\n *\n * `visibleTo` is not touched: the rules let only an org-wide member move a\n * record's scope, and this route is not the place that does it. `hostId`\n * and `createdByUid` are provenance and never rewritten. The notification\n * opens under the site the caller is on, or — at the organization level —\n * under the task's own site, and under the org hub for a task with none.\n */\nasync function updateTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const existing = await tasks.doc(taskId).get()\n if (!existing.exists) return refuse(404, 'That task no longer exists.')\n if (!canReach(writer, existing.get('visibleTo'))) {\n return refuse(403, 'That task is not visible to you.')\n }\n const previousAssignee = String(existing.get('assigneeUid') ?? '') || null\n const previousLinks = crmNextActivityLinksOf(existing.data() as Record<string, unknown>)\n const stored = storedFields(fields, 'update')\n await tasks.doc(taskId).update({\n ...stored,\n ...reminderFields(fields, existing),\n // The title is what the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...existing.data(), ...stored }),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Both sides: a task moved off one deal onto another leaves neither stale.\n await settleNextActivity(writer.orgId, [previousLinks, fields])\n const linkHostId =\n scope.level === 'site'\n ? scope.hostId\n : String(existing.get('hostId') ?? '').trim() || null\n const notified = await notifyAssignee(writer, fields, previousAssignee, linkHostId)\n return { ok: true, notified }\n}\n\n/**\n * Where a NEW task is filed from: the mounted site, or at the organization\n * level the site the body names — which has to be one of THIS org's, or an\n * org-wide member of one org could stamp a task with another org's site\n * token — or no site at all, the organization's own.\n */\nasync function resolveFiledFrom(\n writer: Writer,\n scope: CrmRouteScope,\n): Promise<{ ok: true; hostId: string | null } | Refusal> {\n if (scope.level === 'site') return { ok: true, hostId: scope.hostId }\n if (!scope.hostId) return { ok: true, hostId: null }\n const owner = await getOrgForHost(scope.hostId).catch(() => null)\n if (!owner || owner.orgId !== writer.orgId) {\n return refuse(400, 'That site is not one of this organization’s.')\n }\n return { ok: true, hostId: scope.hostId }\n}\n\n/**\n * Create one task, stamped with its scope and provenance.\n *\n * The scope every CRM creator stamps for a task filed from a site: the\n * whole org when the org has chosen that, and otherwise the sites this\n * site presents as one sender with — which, undeclared, is this site\n * alone. A scoped member creating a record they could not then see is\n * refused, which is the rules' `canCreateScoped()` said before the write\n * instead of after. An organization task — no site — carries\n * `CRM_ORG_TASK_SCOPE` and `hostId: null`.\n */\nasync function createTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; taskId: string; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const filed = await resolveFiledFrom(writer, scope)\n if (filed.ok === false) return filed\n const { hostId } = filed\n const visibleTo = hostId\n ? crmScopeTokens(writer.org, consentGroupForHost(writer.org, hostId))\n : [...CRM_ORG_TASK_SCOPE]\n if (!canReach(writer, visibleTo)) {\n return refuse(403, 'Your access to this organization does not reach this site.')\n }\n const ref = tasks.doc()\n await ref.set({\n ...storedFields(fields, 'create'),\n ...reminderFields(fields, null),\n // What the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...storedFields(fields, 'create'), visibleTo }),\n status: 'open',\n completedAtMs: null,\n visibleTo,\n hostId,\n createdByUid: writer.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [fields])\n const notified = await notifyAssignee(writer, fields, null, hostId)\n return { ok: true, taskId: ref.id, notified }\n}\n\n/**\n * `POST crm/task-save` — create or update a task, and tell its assignee.\n *\n * Body: `{ hostId, taskId?, task: CrmTaskFields }` under a site;\n * `{ orgId, hostId?, taskId?, task }` at the organization level; or the\n * org-level batch `{ orgId, tasks: [{ taskId, task }] }`. Answers\n * `{ ok, taskId, notified }`, or `{ ok, results }` for the batch.\n *\n * The NOTIFICATION fires when the saved assignee is somebody other than the\n * person saving AND is not who the task was already assigned to. Assigning\n * a task to yourself is a note to self; re-saving a task's title does not\n * re-announce it to the person who already has it. The assignee must be a\n * member of the org — a uid nobody can find in the roster is refused rather\n * than written, because a notification to a stranger's inbox is the one\n * thing this route must never do.\n */\nexport const crmTaskSaveHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n if (Array.isArray(req.body?.tasks)) {\n await saveBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n const read = readCrmTaskFields(req.body?.task)\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const fields = read.fields\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n let body: CrmTaskSaveResponse\n if (taskId) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId, notified: saved.notified }\n } else {\n const saved = await createTask(writer, scope, tasks, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId: saved.taskId, notified: saved.notified }\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save failed', error)\n res.status(500).json({ error: 'The task could not be saved.' })\n }\n}\n\n/**\n * The batch: every entry read before any is written, so a body with one\n * unreadable entry is refused whole rather than half-applied; then each\n * task in order, each answered on its own.\n */\nasync function saveBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope,\n): Promise<void> {\n if (scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is saved at the organization level only.',\n })\n return\n }\n const entries = (req.body?.tasks as unknown[]).slice(0, CRM_TASK_BATCH_MAX + 1)\n if (!entries.length || entries.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Save between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n const parsed: Array<{ taskId: string; fields: CrmTaskFields }> = []\n for (const entry of entries) {\n const raw = (entry ?? {}) as Record<string, unknown>\n const taskId = String(raw['taskId'] ?? '').trim()\n const read = readCrmTaskFields(raw['task'])\n if (!taskId || read.ok === false) {\n res.status(400).json({\n error: read.ok === false ? read.error : 'Every task in a batch names its taskId.',\n })\n return\n }\n parsed.push({ taskId, fields: read.fields })\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n const results: CrmTaskSaveOutcome[] = []\n for (const { taskId, fields } of parsed) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n results.push(\n saved.ok === false\n ? { taskId, ok: false, error: saved.body.error }\n : { taskId, ok: true, notified: saved.notified },\n )\n }\n const body: CrmTasksSaveResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save batch failed', error)\n res.status(500).json({ error: 'The tasks could not be saved.' })\n }\n}\n\n/**\n * Mark one task done and fire `taskCompleted` on the site whose automations\n * hear it: under a site, the site whose console ticked the box — workflows\n * belong to a site, and the person is on that site's console. At the\n * organization level there is no such console, so the event goes to the\n * task's OWN site, and an organization task, having none, fires nothing.\n * The task's own `hostId` rides in the payload as `taskHostId` either way,\n * for a workflow that cares about the difference.\n *\n * Idempotent on purpose: two people ticking the same box, or one person\n * double-clicking, completes a task once and fires its event once. The\n * second call reads the stored `completedAtMs` back and writes nothing.\n */\nasync function completeTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n): Promise<CrmTaskCompleteOutcome> {\n const ref = tasks.doc(taskId)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { taskId, ok: false, error: 'That task no longer exists.' }\n }\n const task = snapshot.data() as Partial<CrmTask>\n if (!canReach(writer, task.visibleTo)) {\n return { taskId, ok: false, error: 'That task is not visible to you.' }\n }\n if (task.status === 'done') {\n return {\n taskId,\n ok: true,\n completedAtMs: Number(task.completedAtMs ?? 0),\n alreadyDone: true,\n }\n }\n\n const completedAtMs = Date.now()\n await ref.update({\n status: 'done',\n completedAtMs,\n completedByUid: writer.uid,\n // A reminder still owed on a task that is done is owed to nobody\n // (AGL-2659); one already sent stays, as a fact about what happened.\n ...(crmTaskReminderPending(task) ? { remindAtMs: null } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [task as Record<string, unknown>])\n\n const eventHostId =\n scope.level === 'site' ? scope.hostId : String(task.hostId ?? '').trim()\n if (eventHostId) {\n /*\n * The payload is what a workflow filter can read: every field a string,\n * number or boolean, and every optional one present as an empty string\n * rather than absent, so `contactId != \"\"` is a filter somebody can\n * write without knowing whether the key exists.\n */\n await emitHostEvent(eventHostId, 'taskCompleted', {\n taskId,\n title: String(task.title ?? ''),\n kind: String(task.kind ?? ''),\n priority: String(task.priority ?? ''),\n dueAtMs: typeof task.dueAtMs === 'number' ? task.dueAtMs : 0,\n completedAtMs,\n completedByUid: writer.uid,\n assigneeUid: String(task.assigneeUid ?? ''),\n createdByUid: String(task.createdByUid ?? ''),\n contactId: String(task.contactId ?? ''),\n companyId: String(task.companyId ?? ''),\n dealId: String(task.dealId ?? ''),\n taskHostId: String(task.hostId ?? ''),\n })\n }\n return { taskId, ok: true, completedAtMs }\n}\n\n/** A per-task outcome as the single-task route has always answered. */\nconst completeStatus = (outcome: Extract<CrmTaskCompleteOutcome, { ok: false }>) =>\n outcome.error === 'That task no longer exists.' ? 404 : 403\n\n/**\n * `POST crm/task-complete` — mark a task done and fire `taskCompleted`.\n *\n * Body: `{ hostId, taskId }` under a site, `{ orgId, taskId }` at the\n * organization level, or the org-level batch `{ orgId, taskIds }`. Answers\n * `{ ok, completedAtMs, alreadyDone? }`, or `{ ok, results }` for the batch.\n */\nexport const crmTaskCompleteHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (Array.isArray(req.body?.taskIds)) {\n await completeBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n if (!scope || !taskId) {\n res.status(400).json({ error: 'Missing hostId or taskId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const outcome = await completeTask(writer, scope, tasksCollection(writer.orgId), taskId)\n if (outcome.ok === false) {\n res.status(completeStatus(outcome)).json({ error: outcome.error })\n return\n }\n const body: CrmTaskCompleteResponse = {\n ok: true,\n completedAtMs: outcome.completedAtMs,\n ...(outcome.alreadyDone ? { alreadyDone: true } : {}),\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete failed', error)\n res.status(500).json({ error: 'The task could not be completed.' })\n }\n}\n\n/**\n * The batch: the ids deduplicated and capped, then each task in order —\n * in order rather than at once, because each completion may run a site's\n * workflows, and two hundred runners started together is the burst the\n * per-task client call was written to avoid.\n */\nasync function completeBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope | null,\n): Promise<void> {\n if (!scope || scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is completed at the organization level only.',\n })\n return\n }\n const taskIds = [\n ...new Set(\n (req.body?.taskIds as unknown[]).map((id) => String(id ?? '').trim()).filter(Boolean),\n ),\n ]\n if (!taskIds.length || taskIds.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Complete between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const results: CrmTaskCompleteOutcome[] = []\n for (const taskId of taskIds) {\n results.push(await completeTask(writer, scope, tasks, taskId))\n }\n const body: CrmTasksCompleteResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete batch failed', error)\n res.status(500).json({ error: 'The tasks could not be completed.' })\n }\n}\n"],"names":["consentGroupForHost","CRM_COLLECTIONS","crmScopeTokens","crmTaskListFields","crmTaskReminderAfterEdit","crmTaskReminderPending","isOrgWideMember","memberCanSee","crmNextActivityLinksOf","firebaseAdmin","getOrgForHost","memberHasOrgPermission","notifyUsers","recomputeCrmNextTaskAt","resolveOrgMembership","emitHostEvent","FieldValue","CRM_TASK_BATCH_MAX","readCrmTaskFields","CRM_ORG_TASK_SCOPE","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","TASKS_ACT","refuse","status","error","ok","body","ORG_REFUSAL","authorizeCrmWriter","req","scope","options","suiteAct","undefined","level","caller","orgId","needs","refusal","suite","org","uid","staff","member","authorization","String","headers","idToken","startsWith","slice","length","decoded","app","auth","verifyIdToken","resolved","hostId","catch","membership","role","writerRole","suspended","orgSuspended","canReach","writer","visibleTo","settleNextActivity","links","firestore","map","link","console","assignmentLink","fields","base","contactId","encodeURIComponent","dealId","companyId","storedFields","mode","absent","delete","optional","value","present","out","title","kind","priority","dueAtMs","notes","key","assigneeUid","slot","reminderFields","existing","previous","get","remindAtMs","tasksCollection","collection","doc","tasks","rosterOf","known","Map","answer","then","found","Boolean","set","notifyAssignee","previousAssignee","assignee","notified","due","Date","toLocaleString","type","updateTask","taskId","onRoster","exists","previousLinks","data","stored","update","updatedAt","serverTimestamp","linkHostId","trim","resolveFiledFrom","owner","createTask","filed","ref","completedAtMs","createdByUid","createdAt","id","crmTaskSaveHandler","res","method","setHeader","json","Array","isArray","saveBatch","read","task","saved","entries","parsed","entry","raw","push","results","completeTask","snapshot","Number","alreadyDone","now","completedByUid","eventHostId","taskHostId","completeStatus","outcome","crmTaskCompleteHandler","taskIds","completeBatch","Set","filter"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/task-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type AglynOrgMember,\n consentGroupForHost,\n CRM_COLLECTIONS,\n type CrmTask,\n crmScopeTokens,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n crmTaskReminderPending,\n isOrgWideMember,\n memberCanSee,\n type PluginApiHandler,\n type PluginApiRequest,\n type PluginApiResponse,\n} from '@aglyn/aglyn/server'\nimport {\n crmNextActivityLinksOf,\n firebaseAdmin,\n getOrgForHost,\n memberHasOrgPermission,\n notifyUsers,\n recomputeCrmNextTaskAt,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CRM_TASK_BATCH_MAX,\n type CrmTaskCompleteOutcome,\n type CrmTaskCompleteResponse,\n type CrmTaskFields,\n type CrmTaskSaveOutcome,\n type CrmTaskSaveResponse,\n type CrmTasksCompleteResponse,\n type CrmTasksSaveResponse,\n readCrmTaskFields,\n} from '../model/task-routes'\nimport { CRM_ORG_TASK_SCOPE } from '../model/task-scope'\nimport {\n authorizeOrgCaller,\n type CrmRouteScope,\n readCrmRouteScope,\n} from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\n\n/**\n * The two things a task does that a browser must not do alone (AGL-2599).\n *\n * Every other task write is client-direct against the Firestore rules —\n * reopening, deleting, the list's own reads. These two are routes because\n * each has a side effect that lives outside the document:\n *\n * - SAVING a task assigned to somebody else NOTIFIES them, and a\n * notification is an Admin-SDK write into another person's inbox. Rather\n * than a client-direct write followed by a \"please notify\" call — two\n * operations that can disagree about whether the task exists — the route\n * persists the task itself and notifies from the same code path, so a\n * notification can never announce a task that failed to save. It also\n * stamps the scope, the provenance and the timestamps, which is one\n * creator computing `visibleTo` rather than two.\n *\n * - COMPLETING a task EMITS the `taskCompleted` host event, which is what a\n * workflow triggers on. A browser cannot run the workflow runner.\n *\n * Both authenticate the way the other console plugin routes do — the ID\n * token in the `Authorization` header — and then ask the same two questions\n * the rules ask of a `crmTasks` write: is this member allowed to write org\n * data (`owner`/`admin`/`editor`, and `data.manage` resolved through their\n * custom role), and can they reach THIS document's scope.\n *\n * ## The organization variant (AGL-2637)\n *\n * `{ orgId, … }` instead of a site: the org-level hub. The caller is\n * authorized by the ORG — an org-wide member holding `data.manage`, the\n * hub's own gate, never a site collaborator — and reaches every task the\n * org holds, so no scope check is made against the document. A NEW task\n * names the site it is filed from beside the org, or names none and is the\n * organization's own: `hostId: null`, the org scope token alone. Completing\n * emits on the task's OWN site, where its automations live; an\n * organization task has none and emits nothing, as an uncaptured deal's\n * stage change emits nothing.\n *\n * The org variant also takes a BATCH — `taskIds` to complete, `tasks` to\n * save — so the org hub's bulk bar makes one request per action rather\n * than one per task. Each task is answered on its own in `results`; the\n * request as a whole is refused only for what refuses every task alike.\n */\n\nexport type Refusal = {\n ok: false\n status: number\n body: { error: string; reason?: string; code?: string }\n}\n\n/** What the suite gate names when a task route refuses a plan without it. */\nconst TASKS_ACT = \"Working a record's tasks\"\n\nexport interface Writer {\n ok: true\n uid: string\n /** The caller's address, for who set off the runs a write causes (AGL-3376). */\n email: string | null\n staff: boolean\n orgId: string\n org: Record<string, unknown>\n /** The member document under a site; `null` at the org level, where the reach is the org's. */\n member: Partial<AglynOrgMember> | null\n level: CrmRouteScope['level']\n}\n\nexport const refuse = (status: number, error: string): Refusal => ({\n ok: false,\n status,\n body: { error },\n})\n\nconst ORG_REFUSAL =\n 'Editing tasks at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.'\n\nexport interface CrmWriterOptions {\n /**\n * The act a plan without the CRM is refused for, named the way\n * `crmSuiteRefusalMessage` takes it. `null` for a route that asks the plan\n * itself, once it knows what the request writes, so the refusal can name\n * that act — a contact update names the first field it carries. Tasks\n * when unsaid.\n */\n suiteAct?: string | null\n}\n\n/**\n * Who is asking, and whether they may write this CRM at all.\n *\n * Under a site: the org resolved from the site and the rules' own role and\n * permission questions. At the organization level: the org the body names,\n * for an org-wide member with the permission. The scope question — may they\n * reach THIS task — is asked by the caller against the document, because on\n * a create the document does not exist yet and its scope is what the route\n * is about to stamp.\n */\nexport async function authorizeCrmWriter(\n req: PluginApiRequest,\n scope: CrmRouteScope,\n options: CrmWriterOptions = {},\n): Promise<Writer | Refusal> {\n const suiteAct = options.suiteAct === undefined ? TASKS_ACT : options.suiteAct\n if (scope.level === 'org') {\n const caller = await authorizeOrgCaller(req, scope.orgId, {\n needs: 'data.manage',\n refusal: ORG_REFUSAL,\n })\n if (caller.ok === false) return refuse(caller.status, caller.error)\n // The plan after the person (AGL-2787): see `suite-gate.ts`.\n const suite = suiteAct ? crmSuiteRefusal(caller.org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: caller.uid,\n email: caller.email ?? null,\n staff: caller.staff,\n orgId: caller.orgId,\n org: caller.org as Record<string, unknown>,\n member: null,\n level: 'org',\n }\n }\n\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : undefined\n if (!idToken) return refuse(401, 'Unauthenticated')\n\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n const staff = decoded['staff'] === true\n const resolved = await getOrgForHost(scope.hostId).catch(() => null)\n if (!resolved) {\n return refuse(404, 'This site has no organization, so it has no CRM.')\n }\n const { orgId, org } = resolved\n const membership = await resolveOrgMembership(decoded.uid, orgId).catch(\n () => null,\n )\n const member = (membership?.member ?? null) as Partial<AglynOrgMember> | null\n if (!staff) {\n const role = String(member?.role ?? '')\n /*\n * The rules' `canWriteOrgData()` restated: the role list is what an\n * unstamped permission map falls through to, and the permission is the\n * only half a custom role's revocation can reach. A route that checked\n * one and not the other would open a door the rules keep shut.\n */\n const writerRole = role === 'owner' || role === 'admin' || role === 'editor'\n const suspended =\n (member as { orgSuspended?: boolean } | null)?.orgSuspended === true\n if (!member || !writerRole || suspended) {\n return refuse(403, 'Your organization role does not allow editing the CRM.')\n }\n if (!(await memberHasOrgPermission(orgId, member, 'data.manage'))) {\n return refuse(\n 403,\n 'Your organization role does not allow editing the CRM.',\n )\n }\n }\n const suite = suiteAct ? crmSuiteRefusal(org, suiteAct) : null\n if (suite) return { ok: false, status: suite.status, body: suite.body }\n return {\n ok: true,\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n staff,\n orgId,\n org: (org ?? {}) as Record<string, unknown>,\n member,\n level: 'site',\n }\n}\n\n/**\n * The rules' `canReadScoped()`, for a document the route has in hand. An\n * org-level writer was admitted as org-wide, and an org-wide member reads\n * every row.\n */\nexport const canReach = (writer: Writer, visibleTo: unknown): boolean =>\n writer.staff ||\n writer.level === 'org' ||\n isOrgWideMember(writer.member) ||\n memberCanSee(writer.member, visibleTo as string[] | undefined)\n\n/**\n * The records the task names carry `nextTaskAtMs` (AGL-2661), recomputed\n * after every write here. Its own catch: the task IS saved by now, and a\n * denormalized figure that could not move is logged and left for the\n * Fields section's recompute, not reported as a task that failed to save.\n */\nasync function settleNextActivity(\n orgId: string,\n links: readonly (Partial<CrmTaskFields> | Record<string, unknown> | null | undefined)[],\n): Promise<void> {\n try {\n await recomputeCrmNextTaskAt(\n firebaseAdmin.app().firestore(),\n orgId,\n links.map((link) => crmNextActivityLinksOf(link as Record<string, unknown>)),\n )\n } catch (error) {\n console.error('[crm] next activity could not be recomputed', orgId, error)\n }\n}\n\n/**\n * The console path the assignee's notification opens: the record the task\n * is for, in the hub, or the tasks list when it is for nobody in particular.\n *\n * In the `/{hostDocId}/rest` shape every host notification uses — the\n * console rewrites it to `/{orgSlug}/hosts/{subdomain}/rest` when the link\n * is followed, which is why `hostId` and `orgId` travel on the document. An\n * organization task has no site to open under, so its link is the `/org`\n * shape the console rewrites onto the organization's own hub.\n */\nfunction assignmentLink(hostId: string | null, fields: CrmTaskFields): string {\n const base = hostId ? `/${hostId}/crm` : '/org/crm'\n if (fields.contactId) return `${base}/contacts/${encodeURIComponent(fields.contactId)}`\n if (fields.dealId) return `${base}/deals/${encodeURIComponent(fields.dealId)}`\n if (fields.companyId) return `${base}/companies/${encodeURIComponent(fields.companyId)}`\n return `${base}/tasks`\n}\n\n/**\n * The editable fields as a Firestore write.\n *\n * The optional ids and the assignee are ABSENT on the document when unset,\n * never `null` — the record cards query `where('contactId', '==', id)` and\n * the \"My tasks\" view `where('assigneeUid', '==', uid)`, and neither is\n * helped by a null. So a create leaves an unset optional out, and an update\n * DELETES it: an assignee cleared in the drawer has to come off the\n * document, and an update that merely omitted the key would keep them.\n * `dueAtMs` is the one field written as `null` when unset, because every\n * view orders by it and a document missing the field drops out of an\n * `orderBy` entirely.\n */\nfunction storedFields(\n fields: CrmTaskFields,\n mode: 'create' | 'update',\n): Record<string, unknown> {\n const absent = mode === 'update' ? FieldValue.delete() : undefined\n const optional = (value: string | null) =>\n value ? { present: true, value } : { present: mode === 'update', value: absent }\n const out: Record<string, unknown> = {\n title: fields.title,\n kind: fields.kind,\n priority: fields.priority,\n dueAtMs: fields.dueAtMs,\n notes: fields.notes,\n }\n for (const [key, value] of [\n ['assigneeUid', fields.assigneeUid],\n ['contactId', fields.contactId],\n ['companyId', fields.companyId],\n ['dealId', fields.dealId],\n ] as const) {\n const slot = optional(value)\n if (slot.present) out[key] = slot.value\n }\n return out\n}\n\n/**\n * The reminder a save leaves (AGL-2659), as a Firestore write.\n *\n * `crmTaskReminderAfterEdit` decides which reminder the task ends up with;\n * this turns the decision into fields. Written as `null` when unset, like\n * `dueAtMs`, so the document says the reminder was decided. A reminder that\n * MOVED — to a new time, or to none — is a reminder not yet sent, so the\n * runner's mark comes off with it; one that stayed keeps its mark, which is\n * what stops a title correction from re-sending last week's reminder.\n */\nfunction reminderFields(\n fields: CrmTaskFields,\n existing: FirebaseFirestore.DocumentSnapshot | null,\n): Record<string, unknown> {\n const previous = existing\n ? {\n dueAtMs: (existing.get('dueAtMs') as number | null | undefined) ?? null,\n remindAtMs: (existing.get('remindAtMs') as number | null | undefined) ?? null,\n }\n : null\n const remindAtMs = crmTaskReminderAfterEdit({\n dueAtMs: fields.dueAtMs,\n remindAtMs: fields.remindAtMs,\n previous,\n })\n const out: Record<string, unknown> = { remindAtMs }\n if (previous && remindAtMs !== previous.remindAtMs) {\n out['reminderSentAtMs'] = FieldValue.delete()\n }\n return out\n}\n\nconst tasksCollection = (orgId: string) =>\n firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection(CRM_COLLECTIONS.tasks)\n\ntype TasksCollection = ReturnType<typeof tasksCollection>\n\n/**\n * Whether a uid names a member of the org, answered once per uid across a\n * batch: a bar reassigning forty tasks to one person asks the roster once.\n */\nfunction rosterOf(orgId: string) {\n const known = new Map<string, Promise<boolean>>()\n return (uid: string): Promise<boolean> => {\n let answer = known.get(uid)\n if (!answer) {\n answer = resolveOrgMembership(uid, orgId)\n .then((found) => Boolean(found?.member))\n .catch(() => false)\n known.set(uid, answer)\n }\n return answer\n }\n}\n\n/** Tell the assignee, when they are somebody new and somebody else. */\nasync function notifyAssignee(\n writer: Writer,\n fields: CrmTaskFields,\n previousAssignee: string | null,\n hostId: string | null,\n): Promise<boolean> {\n const assignee = fields.assigneeUid\n const notified = Boolean(\n assignee && assignee !== writer.uid && assignee !== previousAssignee,\n )\n if (!notified || !assignee) return false\n const due =\n typeof fields.dueAtMs === 'number'\n ? ` · due ${new Date(fields.dueAtMs).toLocaleString()}`\n : ''\n await notifyUsers([assignee], {\n type: 'content.taskAssigned',\n title: 'Task assigned to you',\n body: `${fields.title}${due}`,\n link: assignmentLink(hostId, fields),\n orgId: writer.orgId,\n ...(hostId ? { hostId } : {}),\n })\n return true\n}\n\n/**\n * Rewrite one existing task's editable fields, and tell a new assignee.\n *\n * `visibleTo` is not touched: the rules let only an org-wide member move a\n * record's scope, and this route is not the place that does it. `hostId`\n * and `createdByUid` are provenance and never rewritten. The notification\n * opens under the site the caller is on, or — at the organization level —\n * under the task's own site, and under the org hub for a task with none.\n */\nasync function updateTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const existing = await tasks.doc(taskId).get()\n if (!existing.exists) return refuse(404, 'That task no longer exists.')\n if (!canReach(writer, existing.get('visibleTo'))) {\n return refuse(403, 'That task is not visible to you.')\n }\n const previousAssignee = String(existing.get('assigneeUid') ?? '') || null\n const previousLinks = crmNextActivityLinksOf(existing.data() as Record<string, unknown>)\n const stored = storedFields(fields, 'update')\n await tasks.doc(taskId).update({\n ...stored,\n ...reminderFields(fields, existing),\n // The title is what the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...existing.data(), ...stored }),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // Both sides: a task moved off one deal onto another leaves neither stale.\n await settleNextActivity(writer.orgId, [previousLinks, fields])\n const linkHostId =\n scope.level === 'site'\n ? scope.hostId\n : String(existing.get('hostId') ?? '').trim() || null\n const notified = await notifyAssignee(writer, fields, previousAssignee, linkHostId)\n return { ok: true, notified }\n}\n\n/**\n * Where a NEW task is filed from: the mounted site, or at the organization\n * level the site the body names — which has to be one of THIS org's, or an\n * org-wide member of one org could stamp a task with another org's site\n * token — or no site at all, the organization's own.\n */\nasync function resolveFiledFrom(\n writer: Writer,\n scope: CrmRouteScope,\n): Promise<{ ok: true; hostId: string | null } | Refusal> {\n if (scope.level === 'site') return { ok: true, hostId: scope.hostId }\n if (!scope.hostId) return { ok: true, hostId: null }\n const owner = await getOrgForHost(scope.hostId).catch(() => null)\n if (!owner || owner.orgId !== writer.orgId) {\n return refuse(400, 'That site is not one of this organization’s.')\n }\n return { ok: true, hostId: scope.hostId }\n}\n\n/**\n * Create one task, stamped with its scope and provenance.\n *\n * The scope every CRM creator stamps for a task filed from a site: the\n * whole org when the org has chosen that, and otherwise the sites this\n * site presents as one sender with — which, undeclared, is this site\n * alone. A scoped member creating a record they could not then see is\n * refused, which is the rules' `canCreateScoped()` said before the write\n * instead of after. An organization task — no site — carries\n * `CRM_ORG_TASK_SCOPE` and `hostId: null`.\n */\nasync function createTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n fields: CrmTaskFields,\n onRoster: (uid: string) => Promise<boolean>,\n): Promise<{ ok: true; taskId: string; notified: boolean } | Refusal> {\n if (fields.assigneeUid && !(await onRoster(fields.assigneeUid))) {\n return refuse(400, 'The assignee is not a member of this organization.')\n }\n const filed = await resolveFiledFrom(writer, scope)\n if (filed.ok === false) return filed\n const { hostId } = filed\n const visibleTo = hostId\n ? crmScopeTokens(writer.org, consentGroupForHost(writer.org, hostId))\n : [...CRM_ORG_TASK_SCOPE]\n if (!canReach(writer, visibleTo)) {\n return refuse(403, 'Your access to this organization does not reach this site.')\n }\n const ref = tasks.doc()\n await ref.set({\n ...storedFields(fields, 'create'),\n ...reminderFields(fields, null),\n // What the Tasks list searches (AGL-3321).\n ...crmTaskListFields({ ...storedFields(fields, 'create'), visibleTo }),\n status: 'open',\n completedAtMs: null,\n visibleTo,\n hostId,\n createdByUid: writer.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [fields])\n const notified = await notifyAssignee(writer, fields, null, hostId)\n return { ok: true, taskId: ref.id, notified }\n}\n\n/**\n * `POST crm/task-save` — create or update a task, and tell its assignee.\n *\n * Body: `{ hostId, taskId?, task: CrmTaskFields }` under a site;\n * `{ orgId, hostId?, taskId?, task }` at the organization level; or the\n * org-level batch `{ orgId, tasks: [{ taskId, task }] }`. Answers\n * `{ ok, taskId, notified }`, or `{ ok, results }` for the batch.\n *\n * The NOTIFICATION fires when the saved assignee is somebody other than the\n * person saving AND is not who the task was already assigned to. Assigning\n * a task to yourself is a note to self; re-saving a task's title does not\n * re-announce it to the person who already has it. The assignee must be a\n * member of the org — a uid nobody can find in the roster is refused rather\n * than written, because a notification to a stranger's inbox is the one\n * thing this route must never do.\n */\nexport const crmTaskSaveHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n if (Array.isArray(req.body?.tasks)) {\n await saveBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n const read = readCrmTaskFields(req.body?.task)\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const fields = read.fields\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n let body: CrmTaskSaveResponse\n if (taskId) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId, notified: saved.notified }\n } else {\n const saved = await createTask(writer, scope, tasks, fields, onRoster)\n if (saved.ok === false) {\n res.status(saved.status).json(saved.body)\n return\n }\n body = { ok: true, taskId: saved.taskId, notified: saved.notified }\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save failed', error)\n res.status(500).json({ error: 'The task could not be saved.' })\n }\n}\n\n/**\n * The batch: every entry read before any is written, so a body with one\n * unreadable entry is refused whole rather than half-applied; then each\n * task in order, each answered on its own.\n */\nasync function saveBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope,\n): Promise<void> {\n if (scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is saved at the organization level only.',\n })\n return\n }\n const entries = (req.body?.tasks as unknown[]).slice(0, CRM_TASK_BATCH_MAX + 1)\n if (!entries.length || entries.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Save between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n const parsed: Array<{ taskId: string; fields: CrmTaskFields }> = []\n for (const entry of entries) {\n const raw = (entry ?? {}) as Record<string, unknown>\n const taskId = String(raw['taskId'] ?? '').trim()\n const read = readCrmTaskFields(raw['task'])\n if (!taskId || read.ok === false) {\n res.status(400).json({\n error: read.ok === false ? read.error : 'Every task in a batch names its taskId.',\n })\n return\n }\n parsed.push({ taskId, fields: read.fields })\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const onRoster = rosterOf(writer.orgId)\n const results: CrmTaskSaveOutcome[] = []\n for (const { taskId, fields } of parsed) {\n const saved = await updateTask(writer, scope, tasks, taskId, fields, onRoster)\n results.push(\n saved.ok === false\n ? { taskId, ok: false, error: saved.body.error }\n : { taskId, ok: true, notified: saved.notified },\n )\n }\n const body: CrmTasksSaveResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-save batch failed', error)\n res.status(500).json({ error: 'The tasks could not be saved.' })\n }\n}\n\n/**\n * Mark one task done and fire `taskCompleted` on the site whose automations\n * hear it: under a site, the site whose console ticked the box — workflows\n * belong to a site, and the person is on that site's console. At the\n * organization level there is no such console, so the event goes to the\n * task's OWN site, and an organization task, having none, fires nothing.\n * The task's own `hostId` rides in the payload as `taskHostId` either way,\n * for a workflow that cares about the difference.\n *\n * Idempotent on purpose: two people ticking the same box, or one person\n * double-clicking, completes a task once and fires its event once. The\n * second call reads the stored `completedAtMs` back and writes nothing.\n */\nasync function completeTask(\n writer: Writer,\n scope: CrmRouteScope,\n tasks: TasksCollection,\n taskId: string,\n): Promise<CrmTaskCompleteOutcome> {\n const ref = tasks.doc(taskId)\n const snapshot = await ref.get()\n if (!snapshot.exists) {\n return { taskId, ok: false, error: 'That task no longer exists.' }\n }\n const task = snapshot.data() as Partial<CrmTask>\n if (!canReach(writer, task.visibleTo)) {\n return { taskId, ok: false, error: 'That task is not visible to you.' }\n }\n if (task.status === 'done') {\n return {\n taskId,\n ok: true,\n completedAtMs: Number(task.completedAtMs ?? 0),\n alreadyDone: true,\n }\n }\n\n const completedAtMs = Date.now()\n await ref.update({\n status: 'done',\n completedAtMs,\n completedByUid: writer.uid,\n // A reminder still owed on a task that is done is owed to nobody\n // (AGL-2659); one already sent stays, as a fact about what happened.\n ...(crmTaskReminderPending(task) ? { remindAtMs: null } : {}),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await settleNextActivity(writer.orgId, [task as Record<string, unknown>])\n\n const eventHostId =\n scope.level === 'site' ? scope.hostId : String(task.hostId ?? '').trim()\n if (eventHostId) {\n /*\n * The payload is what a workflow filter can read: every field a string,\n * number or boolean, and every optional one present as an empty string\n * rather than absent, so `contactId != \"\"` is a filter somebody can\n * write without knowing whether the key exists.\n */\n await emitHostEvent(\n eventHostId,\n 'taskCompleted',\n {\n taskId,\n title: String(task.title ?? ''),\n kind: String(task.kind ?? ''),\n priority: String(task.priority ?? ''),\n dueAtMs: typeof task.dueAtMs === 'number' ? task.dueAtMs : 0,\n completedAtMs,\n completedByUid: writer.uid,\n assigneeUid: String(task.assigneeUid ?? ''),\n createdByUid: String(task.createdByUid ?? ''),\n contactId: String(task.contactId ?? ''),\n companyId: String(task.companyId ?? ''),\n dealId: String(task.dealId ?? ''),\n taskHostId: String(task.hostId ?? ''),\n },\n { actor: { kind: 'member', uid: writer.uid, email: writer.email } },\n )\n }\n return { taskId, ok: true, completedAtMs }\n}\n\n/** A per-task outcome as the single-task route has always answered. */\nconst completeStatus = (outcome: Extract<CrmTaskCompleteOutcome, { ok: false }>) =>\n outcome.error === 'That task no longer exists.' ? 404 : 403\n\n/**\n * `POST crm/task-complete` — mark a task done and fire `taskCompleted`.\n *\n * Body: `{ hostId, taskId }` under a site, `{ orgId, taskId }` at the\n * organization level, or the org-level batch `{ orgId, taskIds }`. Answers\n * `{ ok, completedAtMs, alreadyDone? }`, or `{ ok, results }` for the batch.\n */\nexport const crmTaskCompleteHandler: 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 scope = readCrmRouteScope(req.body as Record<string, unknown>)\n if (Array.isArray(req.body?.taskIds)) {\n await completeBatch(req, res, scope)\n return\n }\n const taskId = String(req.body?.taskId ?? '').trim()\n if (!scope || !taskId) {\n res.status(400).json({ error: 'Missing hostId or taskId' })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const outcome = await completeTask(writer, scope, tasksCollection(writer.orgId), taskId)\n if (outcome.ok === false) {\n res.status(completeStatus(outcome)).json({ error: outcome.error })\n return\n }\n const body: CrmTaskCompleteResponse = {\n ok: true,\n completedAtMs: outcome.completedAtMs,\n ...(outcome.alreadyDone ? { alreadyDone: true } : {}),\n }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete failed', error)\n res.status(500).json({ error: 'The task could not be completed.' })\n }\n}\n\n/**\n * The batch: the ids deduplicated and capped, then each task in order —\n * in order rather than at once, because each completion may run a site's\n * workflows, and two hundred runners started together is the burst the\n * per-task client call was written to avoid.\n */\nasync function completeBatch(\n req: PluginApiRequest,\n res: PluginApiResponse,\n scope: CrmRouteScope | null,\n): Promise<void> {\n if (!scope || scope.level !== 'org') {\n res.status(400).json({\n error: 'A batch of tasks is completed at the organization level only.',\n })\n return\n }\n const taskIds = [\n ...new Set(\n (req.body?.taskIds as unknown[]).map((id) => String(id ?? '').trim()).filter(Boolean),\n ),\n ]\n if (!taskIds.length || taskIds.length > CRM_TASK_BATCH_MAX) {\n res.status(400).json({\n error: `Complete between 1 and ${CRM_TASK_BATCH_MAX} tasks at a time.`,\n })\n return\n }\n\n try {\n const writer = await authorizeCrmWriter(req, scope)\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n const tasks = tasksCollection(writer.orgId)\n const results: CrmTaskCompleteOutcome[] = []\n for (const taskId of taskIds) {\n results.push(await completeTask(writer, scope, tasks, taskId))\n }\n const body: CrmTasksCompleteResponse = { ok: true, results }\n res.status(200).json(body)\n } catch (error) {\n console.error('[crm] task-complete batch failed', error)\n res.status(500).json({ error: 'The tasks could not be completed.' })\n }\n}\n"],"names":["consentGroupForHost","CRM_COLLECTIONS","crmScopeTokens","crmTaskListFields","crmTaskReminderAfterEdit","crmTaskReminderPending","isOrgWideMember","memberCanSee","crmNextActivityLinksOf","firebaseAdmin","getOrgForHost","memberHasOrgPermission","notifyUsers","recomputeCrmNextTaskAt","resolveOrgMembership","emitHostEvent","FieldValue","CRM_TASK_BATCH_MAX","readCrmTaskFields","CRM_ORG_TASK_SCOPE","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","TASKS_ACT","refuse","status","error","ok","body","ORG_REFUSAL","authorizeCrmWriter","req","scope","options","suiteAct","undefined","level","caller","orgId","needs","refusal","suite","org","uid","email","staff","member","authorization","String","headers","idToken","startsWith","slice","length","decoded","app","auth","verifyIdToken","resolved","hostId","catch","membership","role","writerRole","suspended","orgSuspended","canReach","writer","visibleTo","settleNextActivity","links","firestore","map","link","console","assignmentLink","fields","base","contactId","encodeURIComponent","dealId","companyId","storedFields","mode","absent","delete","optional","value","present","out","title","kind","priority","dueAtMs","notes","key","assigneeUid","slot","reminderFields","existing","previous","get","remindAtMs","tasksCollection","collection","doc","tasks","rosterOf","known","Map","answer","then","found","Boolean","set","notifyAssignee","previousAssignee","assignee","notified","due","Date","toLocaleString","type","updateTask","taskId","onRoster","exists","previousLinks","data","stored","update","updatedAt","serverTimestamp","linkHostId","trim","resolveFiledFrom","owner","createTask","filed","ref","completedAtMs","createdByUid","createdAt","id","crmTaskSaveHandler","res","method","setHeader","json","Array","isArray","saveBatch","read","task","saved","entries","parsed","entry","raw","push","results","completeTask","snapshot","Number","alreadyDone","now","completedByUid","eventHostId","taskHostId","actor","completeStatus","outcome","crmTaskCompleteHandler","taskIds","completeBatch","Set","filter"],"mappings":"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"}
@@ -124,6 +124,8 @@ export const crmDealStageHandler = async (req, res)=>{
124
124
  */ let orgId;
125
125
  let org;
126
126
  let actor = null;
127
+ // Who moved the deal, at either level, for the runs it sets off (AGL-3376).
128
+ let eventActor;
127
129
  if (routeScope.level === 'org') {
128
130
  const caller = await authorizeOrgCaller(req, routeScope.orgId, {
129
131
  needs: 'data.manage',
@@ -140,6 +142,7 @@ export const crmDealStageHandler = async (req, res)=>{
140
142
  uid: caller.uid,
141
143
  email: caller.email
142
144
  };
145
+ eventActor = actor;
143
146
  } else {
144
147
  var _req_headers_authorization;
145
148
  const authorization = String((_req_headers_authorization = req.headers.authorization) != null ? _req_headers_authorization : '');
@@ -151,7 +154,12 @@ export const crmDealStageHandler = async (req, res)=>{
151
154
  }
152
155
  let uid;
153
156
  try {
154
- uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid;
157
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
158
+ uid = decoded.uid;
159
+ eventActor = {
160
+ uid,
161
+ email: typeof decoded.email === 'string' ? decoded.email : null
162
+ };
155
163
  } catch (error) {
156
164
  // A refused credential is the caller's 401; a failure to check one is
157
165
  // ours and keeps a 5xx (AGL-2852).
@@ -273,7 +281,11 @@ export const crmDealStageHandler = async (req, res)=>{
273
281
  await emitHostEvent(eventHostId, event, dealEventPayload(dealId, _extends({}, deal, {
274
282
  stageId: target.id,
275
283
  lostReason: nextStatus === 'lost' ? lostReason : undefined
276
- }), previousStageId));
284
+ }), previousStageId), {
285
+ actor: _extends({
286
+ kind: 'member'
287
+ }, eventActor)
288
+ });
277
289
  }
278
290
  if ((customer == null ? void 0 : customer.outcome) === 'advanced') {
279
291
  // The stage change is the win's effect, announced after its cause, to
@@ -284,6 +296,10 @@ export const crmDealStageHandler = async (req, res)=>{
284
296
  email: customer.email,
285
297
  lifecycleStage: customer.lifecycleStage,
286
298
  previousStage: customer.previousStage
299
+ }, {
300
+ actor: _extends({
301
+ kind: 'member'
302
+ }, eventActor)
287
303
  });
288
304
  }
289
305
  if (actor) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server-deal-stage.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 * `POST /api/crm/deal-stage` — the one writer of a deal's stage (AGL-2598).\n *\n * ## Why a route, when the rules would let the browser do it\n *\n * A deal's title, amount and owner are edited client-direct against the\n * Firestore rules, and nothing about a stage change needs a server to be\n * SAFE. What it needs a server for is to be HEARD: a move between stages, a\n * win and a loss are the three moments an automation wants — \"when a deal is\n * won, email the owner\", \"when one reaches negotiation, file a task\" — and a\n * workflow only fires on an event the tenant runtime emits. A stage written\n * by the browser would be a fact nothing could react to. So every surface\n * that moves a deal calls here, and this handler updates the document and\n * then emits `dealStageChanged`, `dealWon` or `dealLost` with a flat payload\n * a workflow filter can read.\n *\n * ## Who may call it\n *\n * Three gates, in the order the cheapest fails first:\n *\n * 1. A verified Firebase session — the console's `authorizedFetch` puts the\n * id token on every call.\n * 2. An admin or editor role ON THIS SITE, resolved by `hostRoleFor` from\n * the org member document, which is the same projection the rules read.\n * 3. The `data.manage` permission in the org — the key the whole CRM is\n * gated on, resolved through the custom-role and per-member layers by\n * `memberHasOrgPermission`, because a member document alone cannot say\n * what a custom role took away.\n *\n * And then the deal has to be VISIBLE to the site: its `visibleTo` must\n * intersect the scope tokens a contact captured on this site would carry.\n * The Admin SDK evaluates no rules, so without this check an editor of one\n * site in an agency could move another client's deals by id.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, dealId, … }` instead of a site: the org-level hub's board. The\n * caller is authorized by the ORG — an org-wide member holding\n * `data.manage`, the org hub's own gate — and no visibility check is made,\n * because an org-wide member reads every row the org holds. The event\n * still goes to the deal's own capturing site, which is where the\n * automations that listen for it live; a deal no site captured has none to\n * tell. And the act is logged in the org's feed, since there is no one\n * site's feed it happened in.\n *\n * ## What it writes\n *\n * `stageId`, `status` (from the target stage's `kind`, never from the body),\n * `stageChangedAtMs`, `closedAtMs` (set on a close, cleared on a reopen) and\n * `lostReason` (kept on a loss, removed on anything else). A move to the\n * stage the deal is already in writes nothing and emits nothing — an\n * automation must not fire twice because a card was dropped where it was.\n *\n * ## A won deal makes its contact a customer (AGL-2641)\n *\n * A win is the same fact as a purchase — the business has decided this\n * person bought — and an order already floors a contact's lifecycle stage\n * at `customer` on capture. So the transition INTO `won` applies the same\n * floor to the contact the deal names, in the same write path, behind no\n * setting: `floorContactLifecycleStage` fills an empty stage or advances an\n * earlier one inside the facet of the site the deal was made on, and never\n * moves anybody back — a customer stays a customer, an evangelist an\n * evangelist. When the stage did move, `contactStageChanged` is announced\n * after `dealWon`, so an automation keyed on \"became a customer\" hears it,\n * and the org's activity line says so. A failure to write the contact does\n * not undo the win: the deal is already closed, and the response reports\n * `customer: null` rather than a 500 for a stage change that did happen.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmDeal,\n type CrmPipeline,\n dealStageById,\n hostRoleFor,\n hostScopeToken,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n type ContactLifecycleFloor,\n firebaseAdmin,\n floorContactLifecycleStage,\n getOrgForHost,\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n closingStage,\n dealEventName,\n dealEventPayload,\n} from './model/deal-board-model'\nimport { authorizeOrgCaller, readCrmRouteScope } from './server/org-caller'\nimport { crmSuiteRefusal } from './server/suite-gate'\n\n/** The most a lost reason may carry — a sentence or two, not a post-mortem. */\nexport const LOST_REASON_MAX = 500\n\nexport const crmDealStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const routeScope = readCrmRouteScope(req.body as Record<string, unknown>)\n const dealId = String(req.body?.dealId ?? '').trim()\n const stageId =\n typeof req.body?.stageId === 'string' ? req.body.stageId.trim() : ''\n const status = req.body?.status\n const closing = status === 'won' || status === 'lost' ? status : null\n const lostReason = String(req.body?.lostReason ?? '')\n .trim()\n .slice(0, LOST_REASON_MAX)\n if (!routeScope || !dealId) {\n return res.status(400).json({ error: 'Missing hostId or dealId' })\n }\n if (!stageId && !closing) {\n return res.status(400).json({\n error: 'Name a stageId to move to, or a status of won or lost.',\n })\n }\n\n /*\n * Who is calling, and the org whose deal it is. The site variant\n * resolves the org from the site and the caller's role on it; the org\n * variant is authorized by the org itself, and the site the body may\n * name beside it is not consulted — the deal's own site, read off the\n * document, is the one its event goes to.\n */\n let orgId: string\n let org: Record<string, unknown>\n let actor: { uid: string; email: string | null } | null = null\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'data.manage',\n refusal:\n 'Moving a deal at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.',\n })\n if (caller.ok === false) {\n return res.status(caller.status).json({ error: caller.error })\n }\n orgId = caller.orgId\n org = caller.org as Record<string, unknown>\n actor = { uid: caller.uid, email: caller.email }\n } else {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\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 res.status(401).json({ error: 'Unauthenticated' })\n console.error('[crm] deal-stage could not verify the caller', error)\n return res.status(500).json({ error: 'The sign-in could not be checked. Try again.' })\n }\n\n const owner = await getOrgForHost(routeScope.hostId).catch(() => null)\n if (!owner) {\n return res.status(404).json({\n error: 'This site has no organization, so it has no deals.',\n })\n }\n const membership = await resolveOrgMembership(uid, owner.orgId).catch(\n () => null,\n )\n const member = membership?.member\n const hostRole = hostRoleFor(member, routeScope.hostId)\n if (!member || (hostRole !== 'admin' && hostRole !== 'editor')) {\n return res.status(403).json({\n error: 'Moving a deal needs an admin or editor role on this site.',\n })\n }\n if (!(await memberHasOrgPermission(owner.orgId, member, 'data.manage'))) {\n return res.status(403).json({\n error: 'Moving a deal requires the \"Manage data\" permission.',\n })\n }\n orgId = owner.orgId\n org = owner.org as Record<string, unknown>\n }\n\n // Deals are the CRM suite's, at either level (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Moving a deal through its pipeline')\n if (suite) return res.status(suite.status).json(suite.body)\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const dealRef = orgRef.collection(CRM_COLLECTIONS.deals).doc(dealId)\n const dealSnapshot = await dealRef.get()\n if (!dealSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown deal' })\n }\n const deal = dealSnapshot.data() as CrmDeal\n\n if (routeScope.level === 'site') {\n const group = await consentGroupForSite(routeScope.hostId, org)\n const readable = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...group.hostIds.map(hostScopeToken),\n ])\n if (!(deal.visibleTo ?? []).some((token) => readable.has(token))) {\n return res.status(403).json({ error: 'This deal is not visible to this site.' })\n }\n }\n\n const pipelineSnapshot = await orgRef\n .collection(CRM_COLLECTIONS.pipelines)\n .doc(String(deal.pipelineId ?? ''))\n .get()\n if (!pipelineSnapshot.exists) {\n return res.status(409).json({\n error: \"This deal's pipeline no longer exists, so it has no stages to move to.\",\n })\n }\n const pipeline = pipelineSnapshot.data() as CrmPipeline\n const target = stageId\n ? dealStageById(pipeline, stageId)\n : closingStage(pipeline, closing as 'won' | 'lost')\n if (!target) {\n return res.status(400).json({\n error: stageId\n ? 'This pipeline has no such stage.'\n : `This pipeline has no ${closing} stage.`,\n })\n }\n\n const previousStageId = String(deal.stageId ?? '')\n const nextStatus = target.kind\n if (target.id === previousStageId && deal.status === nextStatus) {\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event: null,\n })\n }\n\n const nowMs = Date.now()\n await dealRef.update({\n stageId: target.id,\n status: nextStatus,\n stageChangedAtMs: nowMs,\n closedAtMs: nextStatus === 'open' ? null : nowMs,\n lostReason: nextStatus === 'lost' ? lostReason : FieldValue.delete(),\n updatedAt: new Date(),\n })\n\n const event = dealEventName(nextStatus)\n // The site whose automations hear the move: the mounted site, or at the\n // organization level the deal's own — a deal no site captured has none.\n const eventHostId =\n routeScope.level === 'site' ? routeScope.hostId : String(deal.hostId ?? '').trim()\n\n /*\n * The customer floor, on the transition into `won` alone: a move between\n * open stages, a loss and a reopen say nothing about whether the person\n * bought. The facet written is the site's the deal was made on — under\n * a site the mounted one, at the organization level the deal's own, and\n * for a deal no site captured the person's own capturing site.\n */\n let customer: ContactLifecycleFloor | null = null\n const contactId = String(deal.contactId ?? '').trim()\n if (nextStatus === 'won' && contactId) {\n customer = await floorContactLifecycleStage({\n contactRef: orgRef.collection('contacts').doc(contactId),\n org,\n hostId: eventHostId || null,\n floor: 'customer',\n }).catch((error: unknown) => {\n console.error('[crm] deal-stage: the customer floor failed', dealId, contactId, error)\n return null\n })\n }\n const becameCustomer = customer?.outcome === 'advanced'\n\n if (eventHostId) {\n await emitHostEvent(\n eventHostId,\n event,\n dealEventPayload(\n dealId,\n {\n ...deal,\n stageId: target.id,\n lostReason: nextStatus === 'lost' ? lostReason : undefined,\n },\n previousStageId,\n ),\n )\n }\n if (customer?.outcome === 'advanced') {\n // The stage change is the win's effect, announced after its cause, to\n // the site whose facet moved — the same site as the win's, unless no\n // site captured the deal and the person's own site holds the stage.\n await emitHostEvent(customer.hostId, 'contactStageChanged', {\n contactId: customer.contactId,\n email: customer.email,\n lifecycleStage: customer.lifecycleStage,\n previousStage: customer.previousStage,\n })\n }\n if (actor) {\n await logOrgActivity(\n orgId,\n actor,\n nextStatus === 'won'\n ? becameCustomer\n ? 'Marked deal won — contact now a customer'\n : 'Marked deal won'\n : nextStatus === 'lost'\n ? 'Marked deal lost'\n : `Moved deal to ${target.name}`,\n { type: 'deal', id: dealId, name: String(deal.title ?? '') },\n )\n }\n\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event,\n /*\n * What the win did to the contact: the stage it now holds and whether\n * this call raised it. `null` on anything but a win, on a deal that\n * names no contact, and on a contact write that failed.\n */\n customer:\n customer && (customer.outcome === 'advanced' || customer.outcome === 'held')\n ? {\n contactId: customer.contactId,\n lifecycleStage: customer.lifecycleStage,\n advanced: customer.outcome === 'advanced',\n }\n : null,\n })\n}\n"],"names":["CRM_COLLECTIONS","dealStageById","hostRoleFor","hostScopeToken","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","floorContactLifecycleStage","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","emitHostEvent","FieldValue","closingStage","dealEventName","dealEventPayload","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","LOST_REASON_MAX","crmDealStageHandler","req","res","deal","method","setHeader","status","json","error","routeScope","body","dealId","String","trim","stageId","closing","lostReason","slice","orgId","org","actor","level","caller","needs","refusal","ok","uid","email","authorization","headers","idToken","startsWith","length","app","auth","verifyIdToken","console","owner","hostId","catch","membership","member","hostRole","suite","firestore","orgRef","collection","doc","dealRef","deals","dealSnapshot","get","exists","data","group","readable","Set","hostIds","map","visibleTo","some","token","has","pipelineSnapshot","pipelines","pipelineId","pipeline","target","previousStageId","nextStatus","kind","id","event","nowMs","Date","now","update","stageChangedAtMs","closedAtMs","delete","updatedAt","eventHostId","customer","contactId","contactRef","floor","becameCustomer","outcome","undefined","lifecycleStage","previousStage","name","type","title","advanced"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/server-deal-stage.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 * `POST /api/crm/deal-stage` — the one writer of a deal's stage (AGL-2598).\n *\n * ## Why a route, when the rules would let the browser do it\n *\n * A deal's title, amount and owner are edited client-direct against the\n * Firestore rules, and nothing about a stage change needs a server to be\n * SAFE. What it needs a server for is to be HEARD: a move between stages, a\n * win and a loss are the three moments an automation wants — \"when a deal is\n * won, email the owner\", \"when one reaches negotiation, file a task\" — and a\n * workflow only fires on an event the tenant runtime emits. A stage written\n * by the browser would be a fact nothing could react to. So every surface\n * that moves a deal calls here, and this handler updates the document and\n * then emits `dealStageChanged`, `dealWon` or `dealLost` with a flat payload\n * a workflow filter can read.\n *\n * ## Who may call it\n *\n * Three gates, in the order the cheapest fails first:\n *\n * 1. A verified Firebase session — the console's `authorizedFetch` puts the\n * id token on every call.\n * 2. An admin or editor role ON THIS SITE, resolved by `hostRoleFor` from\n * the org member document, which is the same projection the rules read.\n * 3. The `data.manage` permission in the org — the key the whole CRM is\n * gated on, resolved through the custom-role and per-member layers by\n * `memberHasOrgPermission`, because a member document alone cannot say\n * what a custom role took away.\n *\n * And then the deal has to be VISIBLE to the site: its `visibleTo` must\n * intersect the scope tokens a contact captured on this site would carry.\n * The Admin SDK evaluates no rules, so without this check an editor of one\n * site in an agency could move another client's deals by id.\n *\n * ## The organization variant (AGL-2634)\n *\n * `{ orgId, dealId, … }` instead of a site: the org-level hub's board. The\n * caller is authorized by the ORG — an org-wide member holding\n * `data.manage`, the org hub's own gate — and no visibility check is made,\n * because an org-wide member reads every row the org holds. The event\n * still goes to the deal's own capturing site, which is where the\n * automations that listen for it live; a deal no site captured has none to\n * tell. And the act is logged in the org's feed, since there is no one\n * site's feed it happened in.\n *\n * ## What it writes\n *\n * `stageId`, `status` (from the target stage's `kind`, never from the body),\n * `stageChangedAtMs`, `closedAtMs` (set on a close, cleared on a reopen) and\n * `lostReason` (kept on a loss, removed on anything else). A move to the\n * stage the deal is already in writes nothing and emits nothing — an\n * automation must not fire twice because a card was dropped where it was.\n *\n * ## A won deal makes its contact a customer (AGL-2641)\n *\n * A win is the same fact as a purchase — the business has decided this\n * person bought — and an order already floors a contact's lifecycle stage\n * at `customer` on capture. So the transition INTO `won` applies the same\n * floor to the contact the deal names, in the same write path, behind no\n * setting: `floorContactLifecycleStage` fills an empty stage or advances an\n * earlier one inside the facet of the site the deal was made on, and never\n * moves anybody back — a customer stays a customer, an evangelist an\n * evangelist. When the stage did move, `contactStageChanged` is announced\n * after `dealWon`, so an automation keyed on \"became a customer\" hears it,\n * and the org's activity line says so. A failure to write the contact does\n * not undo the win: the deal is already closed, and the response reports\n * `customer: null` rather than a 500 for a stage change that did happen.\n */\n\nimport {\n CRM_COLLECTIONS,\n type CrmDeal,\n type CrmPipeline,\n dealStageById,\n hostRoleFor,\n hostScopeToken,\n ORG_SCOPE_TOKEN,\n type PluginApiHandler,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n type ContactLifecycleFloor,\n firebaseAdmin,\n floorContactLifecycleStage,\n getOrgForHost,\n logOrgActivity,\n memberHasOrgPermission,\n resolveOrgMembership,\n} from '@aglyn/tenant-data-admin'\nimport { isRefusedIdToken } from '@aglyn/tenant-data-admin/server/id-token-refusal'\nimport { emitHostEvent } from '@aglyn/tenant-runtime'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n closingStage,\n dealEventName,\n dealEventPayload,\n} from './model/deal-board-model'\nimport { authorizeOrgCaller, readCrmRouteScope } from './server/org-caller'\nimport { crmSuiteRefusal } from './server/suite-gate'\n\n/** The most a lost reason may carry — a sentence or two, not a post-mortem. */\nexport const LOST_REASON_MAX = 500\n\nexport const crmDealStageHandler: PluginApiHandler = async (req, res) => {\n if (req.method !== 'POST') {\n res.setHeader('Allow', 'POST')\n return res.status(405).json({ error: 'Method not allowed' })\n }\n\n const routeScope = readCrmRouteScope(req.body as Record<string, unknown>)\n const dealId = String(req.body?.dealId ?? '').trim()\n const stageId =\n typeof req.body?.stageId === 'string' ? req.body.stageId.trim() : ''\n const status = req.body?.status\n const closing = status === 'won' || status === 'lost' ? status : null\n const lostReason = String(req.body?.lostReason ?? '')\n .trim()\n .slice(0, LOST_REASON_MAX)\n if (!routeScope || !dealId) {\n return res.status(400).json({ error: 'Missing hostId or dealId' })\n }\n if (!stageId && !closing) {\n return res.status(400).json({\n error: 'Name a stageId to move to, or a status of won or lost.',\n })\n }\n\n /*\n * Who is calling, and the org whose deal it is. The site variant\n * resolves the org from the site and the caller's role on it; the org\n * variant is authorized by the org itself, and the site the body may\n * name beside it is not consulted — the deal's own site, read off the\n * document, is the one its event goes to.\n */\n let orgId: string\n let org: Record<string, unknown>\n let actor: { uid: string; email: string | null } | null = null\n // Who moved the deal, at either level, for the runs it sets off (AGL-3376).\n let eventActor: { uid: string; email: string | null }\n if (routeScope.level === 'org') {\n const caller = await authorizeOrgCaller(req, routeScope.orgId, {\n needs: 'data.manage',\n refusal:\n 'Moving a deal at the organization level requires the \"Manage data\" ' +\n 'permission across the whole workspace.',\n })\n if (caller.ok === false) {\n return res.status(caller.status).json({ error: caller.error })\n }\n orgId = caller.orgId\n org = caller.org as Record<string, unknown>\n actor = { uid: caller.uid, email: caller.email }\n eventActor = actor\n } else {\n const authorization = String(req.headers.authorization ?? '')\n const idToken = authorization.startsWith('Bearer ')\n ? authorization.slice('Bearer '.length)\n : ''\n if (!idToken) {\n return res.status(401).json({ error: 'Unauthenticated' })\n }\n let uid: string\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n uid = decoded.uid\n eventActor = {\n uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n }\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 res.status(401).json({ error: 'Unauthenticated' })\n console.error('[crm] deal-stage could not verify the caller', error)\n return res.status(500).json({ error: 'The sign-in could not be checked. Try again.' })\n }\n\n const owner = await getOrgForHost(routeScope.hostId).catch(() => null)\n if (!owner) {\n return res.status(404).json({\n error: 'This site has no organization, so it has no deals.',\n })\n }\n const membership = await resolveOrgMembership(uid, owner.orgId).catch(\n () => null,\n )\n const member = membership?.member\n const hostRole = hostRoleFor(member, routeScope.hostId)\n if (!member || (hostRole !== 'admin' && hostRole !== 'editor')) {\n return res.status(403).json({\n error: 'Moving a deal needs an admin or editor role on this site.',\n })\n }\n if (!(await memberHasOrgPermission(owner.orgId, member, 'data.manage'))) {\n return res.status(403).json({\n error: 'Moving a deal requires the \"Manage data\" permission.',\n })\n }\n orgId = owner.orgId\n org = owner.org as Record<string, unknown>\n }\n\n // Deals are the CRM suite's, at either level (AGL-2787).\n const suite = crmSuiteRefusal(org, 'Moving a deal through its pipeline')\n if (suite) return res.status(suite.status).json(suite.body)\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const dealRef = orgRef.collection(CRM_COLLECTIONS.deals).doc(dealId)\n const dealSnapshot = await dealRef.get()\n if (!dealSnapshot.exists) {\n return res.status(404).json({ error: 'Unknown deal' })\n }\n const deal = dealSnapshot.data() as CrmDeal\n\n if (routeScope.level === 'site') {\n const group = await consentGroupForSite(routeScope.hostId, org)\n const readable = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...group.hostIds.map(hostScopeToken),\n ])\n if (!(deal.visibleTo ?? []).some((token) => readable.has(token))) {\n return res.status(403).json({ error: 'This deal is not visible to this site.' })\n }\n }\n\n const pipelineSnapshot = await orgRef\n .collection(CRM_COLLECTIONS.pipelines)\n .doc(String(deal.pipelineId ?? ''))\n .get()\n if (!pipelineSnapshot.exists) {\n return res.status(409).json({\n error: \"This deal's pipeline no longer exists, so it has no stages to move to.\",\n })\n }\n const pipeline = pipelineSnapshot.data() as CrmPipeline\n const target = stageId\n ? dealStageById(pipeline, stageId)\n : closingStage(pipeline, closing as 'won' | 'lost')\n if (!target) {\n return res.status(400).json({\n error: stageId\n ? 'This pipeline has no such stage.'\n : `This pipeline has no ${closing} stage.`,\n })\n }\n\n const previousStageId = String(deal.stageId ?? '')\n const nextStatus = target.kind\n if (target.id === previousStageId && deal.status === nextStatus) {\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event: null,\n })\n }\n\n const nowMs = Date.now()\n await dealRef.update({\n stageId: target.id,\n status: nextStatus,\n stageChangedAtMs: nowMs,\n closedAtMs: nextStatus === 'open' ? null : nowMs,\n lostReason: nextStatus === 'lost' ? lostReason : FieldValue.delete(),\n updatedAt: new Date(),\n })\n\n const event = dealEventName(nextStatus)\n // The site whose automations hear the move: the mounted site, or at the\n // organization level the deal's own — a deal no site captured has none.\n const eventHostId =\n routeScope.level === 'site' ? routeScope.hostId : String(deal.hostId ?? '').trim()\n\n /*\n * The customer floor, on the transition into `won` alone: a move between\n * open stages, a loss and a reopen say nothing about whether the person\n * bought. The facet written is the site's the deal was made on — under\n * a site the mounted one, at the organization level the deal's own, and\n * for a deal no site captured the person's own capturing site.\n */\n let customer: ContactLifecycleFloor | null = null\n const contactId = String(deal.contactId ?? '').trim()\n if (nextStatus === 'won' && contactId) {\n customer = await floorContactLifecycleStage({\n contactRef: orgRef.collection('contacts').doc(contactId),\n org,\n hostId: eventHostId || null,\n floor: 'customer',\n }).catch((error: unknown) => {\n console.error('[crm] deal-stage: the customer floor failed', dealId, contactId, error)\n return null\n })\n }\n const becameCustomer = customer?.outcome === 'advanced'\n\n if (eventHostId) {\n await emitHostEvent(\n eventHostId,\n event,\n dealEventPayload(\n dealId,\n {\n ...deal,\n stageId: target.id,\n lostReason: nextStatus === 'lost' ? lostReason : undefined,\n },\n previousStageId,\n ),\n { actor: { kind: 'member', ...eventActor } },\n )\n }\n if (customer?.outcome === 'advanced') {\n // The stage change is the win's effect, announced after its cause, to\n // the site whose facet moved — the same site as the win's, unless no\n // site captured the deal and the person's own site holds the stage.\n await emitHostEvent(\n customer.hostId,\n 'contactStageChanged',\n {\n contactId: customer.contactId,\n email: customer.email,\n lifecycleStage: customer.lifecycleStage,\n previousStage: customer.previousStage,\n },\n { actor: { kind: 'member', ...eventActor } },\n )\n }\n if (actor) {\n await logOrgActivity(\n orgId,\n actor,\n nextStatus === 'won'\n ? becameCustomer\n ? 'Marked deal won — contact now a customer'\n : 'Marked deal won'\n : nextStatus === 'lost'\n ? 'Marked deal lost'\n : `Moved deal to ${target.name}`,\n { type: 'deal', id: dealId, name: String(deal.title ?? '') },\n )\n }\n\n return res.status(200).json({\n ok: true,\n dealId,\n stageId: target.id,\n status: nextStatus,\n previousStageId,\n event,\n /*\n * What the win did to the contact: the stage it now holds and whether\n * this call raised it. `null` on anything but a win, on a deal that\n * names no contact, and on a contact write that failed.\n */\n customer:\n customer && (customer.outcome === 'advanced' || customer.outcome === 'held')\n ? {\n contactId: customer.contactId,\n lifecycleStage: customer.lifecycleStage,\n advanced: customer.outcome === 'advanced',\n }\n : null,\n })\n}\n"],"names":["CRM_COLLECTIONS","dealStageById","hostRoleFor","hostScopeToken","ORG_SCOPE_TOKEN","consentGroupForSite","firebaseAdmin","floorContactLifecycleStage","getOrgForHost","logOrgActivity","memberHasOrgPermission","resolveOrgMembership","isRefusedIdToken","emitHostEvent","FieldValue","closingStage","dealEventName","dealEventPayload","authorizeOrgCaller","readCrmRouteScope","crmSuiteRefusal","LOST_REASON_MAX","crmDealStageHandler","req","res","deal","method","setHeader","status","json","error","routeScope","body","dealId","String","trim","stageId","closing","lostReason","slice","orgId","org","actor","eventActor","level","caller","needs","refusal","ok","uid","email","authorization","headers","idToken","startsWith","length","decoded","app","auth","verifyIdToken","console","owner","hostId","catch","membership","member","hostRole","suite","firestore","orgRef","collection","doc","dealRef","deals","dealSnapshot","get","exists","data","group","readable","Set","hostIds","map","visibleTo","some","token","has","pipelineSnapshot","pipelines","pipelineId","pipeline","target","previousStageId","nextStatus","kind","id","event","nowMs","Date","now","update","stageChangedAtMs","closedAtMs","delete","updatedAt","eventHostId","customer","contactId","contactRef","floor","becameCustomer","outcome","undefined","lifecycleStage","previousStage","name","type","title","advanced"],"mappings":"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"}
package/src/lib/server.js CHANGED
@@ -127,8 +127,11 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
127
127
  error: 'Unauthenticated'
128
128
  };
129
129
  let uid;
130
+ let email;
130
131
  try {
131
- uid = (await firebaseAdmin.app().auth().verifyIdToken(idToken)).uid;
132
+ const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken);
133
+ uid = decoded.uid;
134
+ email = typeof decoded.email === 'string' ? decoded.email : null;
132
135
  } catch (error) {
133
136
  // A refused credential is the caller's 401; a failure to check one is
134
137
  // ours and keeps a 5xx (AGL-2852).
@@ -162,7 +165,8 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
162
165
  }
163
166
  return {
164
167
  ok: true,
165
- uid
168
+ uid,
169
+ email
166
170
  };
167
171
  }
168
172
  /**
@@ -273,6 +277,12 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
273
277
  email: String((_snapshot_get = snapshot.get('email')) != null ? _snapshot_get : ''),
274
278
  lifecycleStage,
275
279
  previousStage
280
+ }, {
281
+ actor: {
282
+ kind: 'member',
283
+ uid: caller.uid,
284
+ email: caller.email
285
+ }
276
286
  });
277
287
  }
278
288
  res.status(200).json({
@@ -462,6 +472,11 @@ import { CRM_RECIPE_INSTALL_ROUTE, CRM_RECIPE_STATUS_ROUTE, crmRecipeInstallHand
462
472
  interaction: {
463
473
  summary: 'Added by hand'
464
474
  },
475
+ actor: {
476
+ kind: 'member',
477
+ uid: decoded.uid,
478
+ email: typeof decoded.email === 'string' ? decoded.email : null
479
+ },
465
480
  marketingConsent
466
481
  }, marketingConsent && disclosedConsentGroup ? {
467
482
  disclosedConsentGroup
@@ -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 restampCrmListFieldsAt,\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_LIST_FIELDS_ROUTE } from './model/list-fields'\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 { crmListFieldsHandler } from './server/list-fields-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 { CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler } from './server/email-check'\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 // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\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 // The tags are what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contacts.doc(result.contactId), 'contacts')\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 // What a client-direct write the browser cannot follow up asks for: the\n // fields the CRM lists query, restamped from the record (AGL-3321).\n registerPluginApiRoute(CRM_LIST_FIELDS_ROUTE, crmListFieldsHandler)\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 // What the platform knows about one address before somebody writes to it\n // (AGL-3328): the gateway chip on a record page, and the composer's\n // warning about a domain with no mail server or a gateway holding this\n // site's mail. DNS and the ledger are the server's.\n registerPluginApiRoute(CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler)\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","restampCrmListFieldsAt","isRefusedIdToken","captureHostContact","emitHostEvent","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_LIST_FIELDS_ROUTE","CRM_LEAD_SOURCE_VALUES_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","crmListFieldsHandler","crmLeadSourceValuesHandler","registerCrmRecordEmailStateWriter","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmCompaniesImportHandler","crmContactsImportHandler","crmDealsImportHandler","crmLeadsImportHandler","crmTasksImportHandler","crmDealStageHandler","crmEmailSendHandler","CRM_EMAIL_CHECK_ROUTE","crmEmailCheckHandler","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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"}
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 restampCrmListFieldsAt,\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_LIST_FIELDS_ROUTE } from './model/list-fields'\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 { crmListFieldsHandler } from './server/list-fields-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 { CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler } from './server/email-check'\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; email: string | null } | 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 let email: string | null\n try {\n const decoded = await firebaseAdmin.app().auth().verifyIdToken(idToken)\n uid = decoded.uid\n email = typeof decoded.email === 'string' ? decoded.email : null\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, email }\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 // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(snapshot.ref, 'contacts')\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(\n hostId,\n 'contactStageChanged',\n {\n contactId,\n email: String(snapshot.get('email') ?? ''),\n lifecycleStage,\n previousStage,\n },\n { actor: { kind: 'member', uid: caller.uid, email: caller.email } },\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 actor: {\n kind: 'member',\n uid: decoded.uid,\n email: typeof decoded.email === 'string' ? decoded.email : null,\n },\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 // The tags are what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contacts.doc(result.contactId), 'contacts')\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 // What a client-direct write the browser cannot follow up asks for: the\n // fields the CRM lists query, restamped from the record (AGL-3321).\n registerPluginApiRoute(CRM_LIST_FIELDS_ROUTE, crmListFieldsHandler)\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 // What the platform knows about one address before somebody writes to it\n // (AGL-3328): the gateway chip on a record page, and the composer's\n // warning about a domain with no mail server or a gateway holding this\n // site's mail. DNS and the ledger are the server's.\n registerPluginApiRoute(CRM_EMAIL_CHECK_ROUTE, crmEmailCheckHandler)\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","restampCrmListFieldsAt","isRefusedIdToken","captureHostContact","emitHostEvent","FieldValue","CRM_API_ROUTES","registerCrmServerDeclarations","BUNDLE_ID","CRM_NEXT_ACTIVITY_ROUTE","CRM_LIST_FIELDS_ROUTE","CRM_LEAD_SOURCE_VALUES_ROUTE","CRM_TASK_ROUTES","crmNextActivityHandler","crmListFieldsHandler","crmLeadSourceValuesHandler","registerCrmRecordEmailStateWriter","registerCrmRecordTimelineWriter","crmTaskCompleteHandler","crmTaskSaveHandler","crmCompaniesImportHandler","crmContactsImportHandler","crmDealsImportHandler","crmLeadsImportHandler","crmTasksImportHandler","crmDealStageHandler","crmEmailSendHandler","CRM_EMAIL_CHECK_ROUTE","crmEmailCheckHandler","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","email","decoded","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","actor","kind","CONTACT_BAND_FULL_MESSAGE","crmContactsCreateHandler","rawPhone","phone","rawStage","name","jobTitle","companyName","companyId","ownerUid","address","tags","marketingConsent","disclosedConsentGroup","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":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,SACEA,sBAAsB,EACtBC,gBAAgB,EAChBC,eAAe,EACfC,aAAa,EACbC,uBAAuB,EACvBC,eAAe,EACfC,gBAAgB,EAChBC,qBAAqB,EACrBC,cAAc,EAIdC,gBAAgB,EAChBC,sBAAsB,EACtBC,aAAa,QACR,sBAAqB;AAC5B,SACEC,mBAAmB,EACnBC,aAAa,EACbC,aAAa,EACbC,eAAe,EACfC,sBAAsB,EACtBC,wBAAwB,EACxBC,oBAAoB,EACpBC,sBAAsB,QACjB,2BAA0B;AACjC,SAASC,gBAAgB,QAAQ,mDAAkD;AACnF,SAASC,kBAAkB,EAAEC,aAAa,QAAQ,wBAAuB;AACzE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,cAAc,QAAQ,4BAAwB;AACvD,SAASC,6BAA6B,QAAQ,2BAAuB;AACrE,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,uBAAuB,QAAQ,2BAAuB;AAC/D,SAASC,qBAAqB,QAAQ,yBAAqB;AAC3D,SAASC,4BAA4B,QAAQ,gCAA4B;AACzE,SAASC,eAAe,QAAQ,yBAAqB;AACrD,SAASC,sBAAsB,QAAQ,mCAA+B;AACtE,SAASC,oBAAoB,QAAQ,iCAA6B;AAClE,SAASC,0BAA0B,QAAQ,iCAA6B;AACxE,SAASC,iCAAiC,QAAQ,iCAA6B;AAC/E,SAASC,+BAA+B,QAAQ,8BAA0B;AAC1E,SAASC,sBAAsB,EAAEC,kBAAkB,QAAQ,0BAAsB;AACjF,SAASC,yBAAyB,QAAQ,+BAA2B;AACrE,SAASC,wBAAwB,QAAQ,8BAA0B;AACnE,SAASC,qBAAqB,QAAQ,2BAAuB;AAC7D,SAASC,qBAAqB,QAAQ,2BAAuB;AAC7D,SAASC,qBAAqB,QAAQ,2BAAuB;AAC7D,SAASC,mBAAmB,QAAQ,yBAAqB;AACzD,SAASC,mBAAmB,QAAQ,yBAAqB;AACzD,SAASC,qBAAqB,EAAEC,oBAAoB,QAAQ,0BAAsB;AAClF,SAASC,kBAAkB,QAAQ,2BAAuB;AAC1D,SAASC,iBAAiB,QAAQ,0BAAsB;AACxD,SACEC,2BAA2B,EAC3BC,0BAA0B,QACrB,oCAAgC;AACvC,SAASC,oBAAoB,EAAEC,oBAAoB,QAAQ,6BAAyB;AACpF,SAASC,sBAAsB,EAAEC,qBAAqB,QAAQ,2BAAuB;AACrF,SAASC,sBAAsB,EAAEC,qBAAqB,QAAQ,2BAAuB;AACrF,SAASC,yBAAyB,EAAEC,wBAAwB,QAAQ,8BAA0B;AAC9F,SAASC,uBAAuB,QAAQ,6BAAyB;AACjE,SAASC,qBAAqB,EAAEC,aAAa,EAAEC,KAAK,QAAQ,8BAA0B;AACtF,SAASC,uBAAuB,QAAQ,6BAAyB;AACjE,SACEC,kCAAkC,EAClCC,gCAAgC,QAC3B,uCAAmC;AAC1C,SAASC,6BAA6B,QAAQ,2BAAuB;AACrE,SAASC,eAAe,QAAQ,yBAAqB;AACrD,SACEC,wBAAwB,EACxBC,uBAAuB,EACvBC,uBAAuB,EACvBC,sBAAsB,QACjB,4BAAwB;AAE/B;;;;;;;CAOC,GACD,OAAO,MAAMC,iBAAmC,CAACC,KAAKC;IACpD,IAAID,IAAIE,MAAM,KAAK,OAAO;QACxBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACAL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;QAAEE,IAAI;QAAMC,QAAQvD;IAAU;AACrD,EAAC;AAID;;;;;;;;;CASC,GACD,eAAewD,oBACbT,GAAqB,EACrBU,MAAc;QAEeV,4BA2BTW;IA3BpB,MAAMC,gBAAgBC,QAAOb,6BAAAA,IAAIc,OAAO,CAACF,aAAa,YAAzBZ,6BAA6B;IAC1D,MAAMe,UAAUH,cAAcI,UAAU,CAAC,aACrCJ,cAAcK,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS,OAAO;QAAER,IAAI;QAAOH,QAAQ;QAAKE,OAAO;IAAkB;IACxE,IAAIc;IACJ,IAAIC;IACJ,IAAI;QACF,MAAMC,UAAU,MAAMlF,cAAcmF,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACV;QAC/DK,MAAME,QAAQF,GAAG;QACjBC,QAAQ,OAAOC,QAAQD,KAAK,KAAK,WAAWC,QAAQD,KAAK,GAAG;IAC9D,EAAE,OAAOf,OAAO;QACd,sEAAsE;QACtE,mCAAmC;QACnC,IAAI3D,iBAAiB2D,QAAQ,OAAO;YAAEC,IAAI;YAAOH,QAAQ;YAAKE,OAAO;QAAkB;QACvFoB,QAAQpB,KAAK,CAAC,+CAA+CA;QAC7D,OAAO;YAAEC,IAAI;YAAOH,QAAQ;YAAKE,OAAO;QAA+C;IACzF;IACA,MAAMK,eAAe,MAAMvE,cACxBmF,GAAG,GACHI,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACnB,QACJoB,GAAG;IACN,IAAI,CAACnB,aAAaoB,MAAM,EAAE;QACxB,OAAO;YAAExB,IAAI;YAAOH,QAAQ;YAAKE,OAAO;QAAe;IACzD;IACA,MAAM0B,aAAa,EAACrB,oBAAAA,aAAamB,GAAG,CAAC,0BAAjBnB,oBAAmC,CAAC,EAAE,CAACS,IAAI;IAC/D,IAAIY,eAAe,WAAWA,eAAe,UAAU;QACrD,OAAO;YAAEzB,IAAI;YAAOH,QAAQ;YAAKE,OAAO;QAA6B;IACvE;IACA,OAAO;QAAEC,IAAI;QAAMa;QAAKC;IAAM;AAChC;AAEA;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GACD,OAAO,MAAMY,sBAAwC,OAAOjC,KAAKC;;QAMzCD,WACGA,YACEA;IAP3B,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IACA,MAAMI,SAASG,gBAAOb,YAAAA,IAAIkC,IAAI,qBAARlC,UAAUU,MAAM,mBAAI,IAAIyB,IAAI;IAClD,MAAMC,YAAYvB,iBAAOb,aAAAA,IAAIkC,IAAI,qBAARlC,WAAUoC,SAAS,oBAAI,IAAID,IAAI;IACxD,MAAME,aAAqBrC,aAAAA,IAAIkC,IAAI,qBAARlC,WAAUsC,cAAc;IACnD,IAAI,CAAC5B,UAAU,CAAC0B,WAAW;QACzBnC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA8B;QAC5D;IACF;IACA,sEAAsE;IACtE,MAAMiC,WAAWF,cAAc;IAC/B,MAAMC,iBAAiBC,WACnB,KACA5G,wBAAwB0G,aACtBA,YACA;IACN,IAAIC,mBAAmB,MAAM;QAC3BrC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAyB;QACvD;IACF;IACA,MAAMkC,SAAS,MAAM/B,oBAAoBT,KAAKU;IAC9C,IAAI8B,OAAOjC,EAAE,KAAK,OAAO;QACvBN,IAAIG,MAAM,CAACoC,OAAOpC,MAAM,EAAEC,IAAI,CAAC;YAAEC,OAAOkC,OAAOlC,KAAK;QAAC;QACrD;IACF;IACA,IAAI;YAuBoBmC;QAtBtB,qEAAqE;QACrE,MAAMC,QAAQ,MAAMrG,cAAcqE;QAClC,IAAI,CAACgC,OAAO;YACVzC,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;YAC7C;QACF;QACA,MAAMqC,QAAQjD,gBAAgBgD,MAAME,GAAG,EAAE;QACzC,IAAID,OAAO;YACT1C,IAAIG,MAAM,CAACuC,MAAMvC,MAAM,EAAEC,IAAI,CAACsC,MAAMT,IAAI;YACxC;QACF;QACA,MAAMW,cAAc,MAAMrG,yBAAyBkE,QAAQ;QAC3D,MAAMoC,WAAW,MAAMD,YAAYhB,GAAG,CAACO,WAAWN,GAAG;QACrD,IAAI,CAACgB,SAASf,MAAM,IAAI,CAAC7F,cAAc4G,SAAShB,GAAG,CAAC,cAAcpB,SAAS;YACzET,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkB;YAChD;QACF;QACA,MAAMyC,QAAQ,MAAM5G,oBAAoBuE;QACxC,MAAM+B,QAAQzG,iBACZ8G,SAASE,IAAI,IACbD,MAAME,OAAO;QAEf,MAAMC,iBAAgBT,wBAAAA,MAAMH,cAAc,YAApBG,wBAAwB;QAC9C,IAAIS,kBAAkBZ,gBAAgB;YACpCrC,IACGG,MAAM,CAAC,KACPC,IAAI,CAAC;gBAAEE,IAAI;gBAAM4C,SAAS;gBAAOb;gBAAgBY;YAAc;YAClE;QACF;QACA,MAAMJ,SAASM,GAAG,CAACC,MAAM,CAAC;YACxB,CAAC7H,iBAAiBuH,MAAME,OAAO,EAAE,kBAAkB,EAAEV,WACjDzF,WAAWwG,MAAM,KACjBhB;YACJiB,WAAWzG,WAAW0G,eAAe;QACvC;QACA,6DAA6D;QAC7D,MAAM9G,uBAAuBoG,SAASM,GAAG,EAAE;QAC3C,uEAAuE;QACvE,yEAAyE;QACzE,IAAI,CAACb,UAAU;gBAMKO;YALlB,MAAMjG,cACJ6D,QACA,uBACA;gBACE0B;gBACAf,OAAOR,QAAOiC,gBAAAA,SAAShB,GAAG,CAAC,oBAAbgB,gBAAyB;gBACvCR;gBACAY;YACF,GACA;gBAAEO,OAAO;oBAAEC,MAAM;oBAAUtC,KAAKoB,OAAOpB,GAAG;oBAAEC,OAAOmB,OAAOnB,KAAK;gBAAC;YAAE;QAEtE;QACApB,IACGG,MAAM,CAAC,KACPC,IAAI,CAAC;YAAEE,IAAI;YAAM4C,SAAS;YAAMb;YAAgBY;QAAc;IACnE,EAAE,OAAO5C,OAAO;QACdoB,QAAQpB,KAAK,CAAC,8BAA8BI,QAAQ0B,WAAW9B;QAC/DL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiC;IACjE;AACF,EAAC;AAED;;;;;;;CAOC,GACD,OAAO,MAAMqD,4BACX,4EACA,sBAAqB;AAEvB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmDC,GACD,OAAO,MAAMC,2BAA6C,OAAO5D,KAAKC;QAOtDD,WAsCeA;IA5C7B,IAAIA,IAAIE,MAAM,KAAK,QAAQ;QACzBD,IAAIE,SAAS,CAAC,SAAS;QACvBF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAqB;QACnD;IACF;IAEA,MAAM4B,QAAQlC,YAAAA,IAAIkC,IAAI,YAARlC,YAAY,CAAC;IAC3B,MAAMU,SAASrB,MAAM6C,IAAI,CAAC,SAAS,EAAE;IACrC,IAAI,CAACxB,QAAQ;QACXT,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAiB;QAC/C;IACF;IACA,MAAMe,QAAQvF,sBAAsBoG,IAAI,CAAC,QAAQ;IACjD,IAAI,CAACb,OAAO;QACVpB,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA+B;QAC7D;IACF;IACA,MAAMuD,WAAWxE,MAAM6C,IAAI,CAAC,QAAQ,EAAE;IACtC,MAAM4B,QAAQD,WAAW9H,eAAe8H,YAAY;IACpD,IAAIA,YAAY,CAACC,OAAO;QACtB7D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAOnB;QAAsB;QACpD;IACF;IACA,MAAM4E,WAAW1E,MAAM6C,IAAI,CAAC,iBAAiB,EAAE;IAC/C,IAAI6B,YAAY,CAACpI,wBAAwBoI,WAAW;QAClD9D,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAA2B;QACzD;IACF;IACA,MAAM0D,OAAO3E,MAAM6C,IAAI,CAAC,OAAO,EAAE;IACjC,MAAM+B,WAAW5E,MAAM6C,IAAI,CAAC,WAAW,EAAE;IACzC,IAAIgC,cAAc7E,MAAM6C,IAAI,CAAC,cAAc,EAAE;IAC7C,MAAMiC,YAAY9E,MAAM6C,IAAI,CAAC,YAAY,EAAE;IAC3C,MAAMkC,WAAW/E,MAAM6C,IAAI,CAAC,WAAW,EAAE;IACzC,MAAMmC,UACJnC,IAAI,CAAC,UAAU,IAAI,OAAOA,IAAI,CAAC,UAAU,KAAK,WAC1CrG,iBAAiBqG,IAAI,CAAC,UAAU,IAChC;IACN,MAAMoC,OAAOlF,cAAc8C,IAAI,CAAC,OAAO;IACvC,MAAMqC,mBAAmBrC,IAAI,CAAC,mBAAmB,KAAK;IACtD,yEAAyE;IACzE,sEAAsE;IACtE,+DAA+D;IAC/D,MAAMsC,wBAAwBnF,MAAM6C,IAAI,CAAC,wBAAwB,EAAE;IAEnE,MAAMtB,gBAAgBC,QAAOb,6BAAAA,IAAIc,OAAO,CAACF,aAAa,YAAzBZ,6BAA6B;IAC1D,MAAMe,UAAUH,cAAcI,UAAU,CAAC,aACrCJ,cAAcK,KAAK,CAAC,UAAUC,MAAM,IACpCC;IACJ,IAAI,CAACJ,SAAS;QACZd,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkB;QAChD;IACF;IAEA,IAAI;YA+I2BgB;QA9I7B,MAAMA,UAAU,MAAMlF,cAAcmF,GAAG,GAAGC,IAAI,GAAGC,aAAa,CAACV;QAC/D,MAAM0D,WAAW,MAAMpI,cAAcqE;QACrC,IAAI,CAAC+D,UAAU;YACbxE,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAe;YAC7C;QACF;QACA,MAAMoE,UAAUpD,OAAO,CAAC,QAAQ,KAAK;QACrC,IAAI,CAACoD,SAAS;;gBAIyBC;YAHrC,MAAMC,aAAa,MAAMnI,qBAAqB6E,QAAQF,GAAG,EAAEqD,SAASI,KAAK;YACzE,MAAMF,iBAASC,8BAAAA,WAAYD,MAAM,mBAAI;YACrC,MAAMG,UACJlJ,gBAAgB+I,WAAWI,QAAQJ,2BAAAA,qBAAAA,OAAQK,UAAU,qBAAlBL,kBAAoB,CAACjE,OAAO;YACjE,MAAMuE,UACJN,UACAG,WACC,MAAMvI,uBAAuBkI,SAASI,KAAK,EAAEF,QAAQ;YACxD,IAAI,CAACM,SAAS;gBACZhF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBACnBC,OAAO;gBACT;gBACA;YACF;QACF;QAEA,uEAAuE;QACvE,wEAAwE;QACxE,MAAMqC,QAAQjD,gBAAgB+E,SAAS7B,GAAG,EAAE;QAC5C,IAAID,OAAO;YACT1C,IAAIG,MAAM,CAACuC,MAAMvC,MAAM,EAAEC,IAAI,CAACsC,MAAMT,IAAI;YACxC;QACF;QAEA,IAAIiC,WAAW;YACb,MAAMpB,QAAQ,MAAM5G,oBAClBuE,QACA+D,SAAS7B,GAAG;YAEd,MAAMsC,WAAW,IAAIC,IAAYzJ,cAAcqH;YAC/C,MAAMqC,kBAAkB,MAAMhJ,cAC3BmF,GAAG,GACHI,SAAS,GACTC,UAAU,CAAC,QACXC,GAAG,CAAC4C,SAASI,KAAK,EAClBjD,UAAU,CAACnG,gBAAgB4J,SAAS,EACpCxD,GAAG,CAACsC,WACJrC,GAAG;YACN,MAAMwD,SAAkBF,gBAAgBrD,MAAM,GAC1CqD,gBAAgBtD,GAAG,CAAC,eACpBX;YACJ,MAAMoE,UACJC,MAAMC,OAAO,CAACH,WAAWA,OAAOI,IAAI,CAAC,CAACC,QAAUT,SAASU,GAAG,CAAC/E,OAAO8E;YACtE,IAAI,CAACJ,SAAS;gBACZtF,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO;gBAAkB;gBAChD;YACF;YACA4D,cAAc7E,MAAM+F,gBAAgBtD,GAAG,CAAC,SAAS,QAAQoC;QAC3D;QAEA,MAAM2B,SAAS,MAAMjJ,mBAAmB;YACtC8D;YACAW;WACI2C,OAAO;YAAEA;QAAK,IAAI,CAAC;YACvB8B,QAAQ;YACRC,aAAa;gBAAEC,SAAS;YAAgB;YACxCvC,OAAO;gBACLC,MAAM;gBACNtC,KAAKE,QAAQF,GAAG;gBAChBC,OAAO,OAAOC,QAAQD,KAAK,KAAK,WAAWC,QAAQD,KAAK,GAAG;YAC7D;YACAkD;WACIA,oBAAoBC,wBACpB;YAAEA;QAAsB,IACxB,CAAC;YACL/B,OAAO,aACDqB,QAAQ;gBAAEA;YAAM,IAAI,CAAC,GACrBG,WAAW;gBAAEA;YAAS,IAAI,CAAC,GAC3BE,YAAY;gBAAEA;YAAU,IAAI,CAAC,GAC7BE,UAAU;gBAAEA;YAAQ,IAAI,CAAC,GACzBD,WAAW;gBAAEA;YAAS,IAAI,CAAC,GAC3BL,YAAYpI,wBAAwBoI,YACpC;gBAAEzB,gBAAgByB;YAAS,IAC3B,CAAC;;QAIT,IAAI,aAAa8B,QAAQ;YACvB,IAAIA,OAAOI,OAAO,KAAK,QAAQ;gBAC7BhG,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAOqD;oBAA2BuC,QAAQ;gBAAO;gBACxE;YACF;YACA,IAAIL,OAAOI,OAAO,KAAK,iBAAiB;gBACtChG,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO;gBAA+B;gBAC7D;YACF;YACA,oEAAoE;YACpE,iEAAiE;YACjE,4DAA4D;YAC5D,IAAIuF,OAAOI,OAAO,KAAK,UAAU;gBAC/BhG,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;oBAAEC,OAAO/E;oBAAwB2K,QAAQ;gBAAS;gBACvE;YACF;YACAjG,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;gBAAEC,OAAO;YAAkC;YAChE;QACF;QAEA,IAAIgE,KAAKpD,MAAM,IAAIgD,aAAa;YAC9B,MAAMnB,QAAQ,MAAM5G,oBAClBuE,QACA+D,SAAS7B,GAAG;YAEd,MAAMuD,WAAW,MAAM3J,yBAAyBkE,QAAQ;YACxD,MAAMyF,SAAStE,GAAG,CAACgE,OAAOzD,SAAS,EAAEiB,MAAM,CAAC,aACtCiB,KAAKpD,MAAM,GACX;gBACE,CAAC1F,iBAAiBuH,MAAME,OAAO,EAAE,QAAQ,EAAEnG,WAAWsJ,UAAU,IAC3D9B;YAEP,IACA,CAAC,GACDJ,cACA;gBACE,CAAC1I,iBAAiBuH,MAAME,OAAO,EAAE,eAAe,EAAEiB;gBAClD,mDAAmD;gBACnDA;YACF,IACA,CAAC;gBACLX,WAAWzG,WAAW0G,eAAe;;YAEvC,6DAA6D;YAC7D,MAAM9G,uBAAuByJ,SAAStE,GAAG,CAACgE,OAAOzD,SAAS,GAAG;QAC/D;QAEA;;;;;;;KAOC,GACD,MAAM9F,gBACJoE,QACA;YAAEU,KAAKE,QAAQF,GAAG;YAAEC,KAAK,GAAEC,iBAAAA,QAAQD,KAAK,YAAbC,iBAAiB;QAAK,GACjDuE,OAAOQ,OAAO,GAAG,kBAAkB,mBACnC;YAAEC,MAAM;YAAWC,IAAIV,OAAOzD,SAAS;YAAE4B,MAAMA,QAAQ3C;QAAM;QAG/DpB,IACGG,MAAM,CAACyF,OAAOQ,OAAO,GAAG,MAAM,KAC9BhG,IAAI,CAAC;YAAE+B,WAAWyD,OAAOzD,SAAS;YAAEiE,SAASR,OAAOQ,OAAO;QAAC;IACjE,EAAE,OAAO/F,OAAO;QACdoB,QAAQpB,KAAK,CAAC,+BAA+BA;QAC7CL,IAAIG,MAAM,CAAC,KAAKC,IAAI,CAAC;YAAEC,OAAO;QAAkC;IAClE;AACF,EAAC;AAED,8EAA8E,GAC9E,OAAO,SAASkG;IACd;;;;;GAKC,GACDxJ;IACAf,uBAAuBc,eAAe0J,IAAI,EAAE1G;IAC5C9D,uBAAuBc,eAAe2J,YAAY,EAAEzE;IACpD,wEAAwE;IACxE,uEAAuE;IACvEhG,uBAAuBc,eAAe4J,aAAa,EAAErH;IACrD,yEAAyE;IACzE,kDAAkD;IAClDrD,uBAAuBc,eAAe6J,aAAa,EAAE1H;IACrD,2EAA2E;IAC3E,oEAAoE;IACpEjD,uBAAuBoB,gBAAgBwJ,IAAI,EAAEjJ;IAC7C3B,uBAAuBoB,gBAAgByJ,QAAQ,EAAEnJ;IACjD,kEAAkE;IAClE1B,uBAAuBiB,yBAAyBI;IAChD,wEAAwE;IACxE,oEAAoE;IACpErB,uBAAuBkB,uBAAuBI;IAC9C,wEAAwE;IACxE,0DAA0D;IAC1DtB,uBAAuBmB,8BAA8BI;IACrD,yEAAyE;IACzE,gCAAgC;IAChCvB,uBAAuB,uBAAuB6B;IAC9C,wEAAwE;IACxE,uDAAuD;IACvD7B,uBAAuB,wBAAwB4B;IAC/C,oEAAoE;IACpE,uEAAuE;IACvE,iCAAiC;IACjC5B,uBAAuB,oBAAoB8B;IAC3C9B,uBAAuB,oBAAoBgC;IAC3C,wEAAwE;IACxE,uEAAuE;IACvE,wEAAwE;IACxE,wBAAwB;IACxBhC,uBAAuB,oBAAoB+B;IAC3C,yEAAyE;IACzE,iEAAiE;IACjE,0BAA0B;IAC1B/B,uBAAuB,kBAAkBiC;IACzC,qEAAqE;IACrE,yEAAyE;IACzE,sCAAsC;IACtCjC,uBAAuB,uBAAuB2H;IAC9C,0EAA0E;IAC1E,0EAA0E;IAC1E,6CAA6C;IAC7C3H,uBAAuB,oBAAoBqC;IAC3C,kEAAkE;IAClErC,uBAAuB,oBAAoBsC;IAC3C,yEAAyE;IACzE,wEAAwE;IACxEtC,uBAAuBuC,6BAA6BC;IACpD,yEAAyE;IACzE,oEAAoE;IACpE,iEAAiE;IACjExC,uBAAuBc,eAAegK,SAAS,EAAE5I;IACjD,yEAAyE;IACzE,oEAAoE;IACpE,uEAAuE;IACvE,oDAAoD;IACpDlC,uBAAuBmC,uBAAuBC;IAC9C,yEAAyE;IACzE,uEAAuE;IACvE,0EAA0E;IAC1EpC,uBAAuByC,sBAAsBC;IAC7C,iEAAiE;IACjE,yBAAyB;IACzB1C,uBAAuB2C,wBAAwBC;IAC/C,wEAAwE;IACxE,yEAAyE;IACzE,6CAA6C;IAC7C5C,uBAAuB6C,wBAAwBC;IAC/C,qEAAqE;IACrE,yEAAyE;IACzE,wEAAwE;IACxE9C,uBAAuB0D,0BAA0BE;IACjD5D,uBAAuB2D,yBAAyBE;IAChD,wEAAwE;IACxE,yEAAyE;IACzE,wCAAwC;IACxC7D,uBAAuB+C,2BAA2BC;IAClDhD,uBACEsD,oCACAC;IAEF,uEAAuE;IACvE,6EAA6E;IAC7E,wEAAwE;IACxE,WAAW;IACXC;IACA,yEAAyE;IACzE,yEAAyE;IACzE,oEAAoE;IACpE/B;IACA,uEAAuE;IACvE,mEAAmE;IACnE,oEAAoE;IACpED;AACF"}