@aglyn/plugins-crm 1.0.0-beta.219 → 1.0.0-beta.220

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.
Files changed (136) hide show
  1. package/README.md +2 -0
  2. package/package.json +14 -15
  3. package/src/lib/components/company-import-drawer.d.ts +1 -1
  4. package/src/lib/components/contact-associations-card.js +4 -3
  5. package/src/lib/components/contact-associations-card.js.map +1 -1
  6. package/src/lib/components/crm-booking-zone.d.ts +8 -2
  7. package/src/lib/components/crm-booking-zone.js.map +1 -1
  8. package/src/lib/components/crm-glance-card.js +2 -1
  9. package/src/lib/components/crm-glance-card.js.map +1 -1
  10. package/src/lib/components/deal-products-card.js +27 -16
  11. package/src/lib/components/deal-products-card.js.map +1 -1
  12. package/src/lib/components/lead-campaigns-card.d.ts +4 -4
  13. package/src/lib/components/lead-campaigns-card.js +9 -8
  14. package/src/lib/components/lead-campaigns-card.js.map +1 -1
  15. package/src/lib/components/lead-properties-card.js +4 -0
  16. package/src/lib/components/lead-properties-card.js.map +1 -1
  17. package/src/lib/components/lead-status-chip.js +3 -1
  18. package/src/lib/components/lead-status-chip.js.map +1 -1
  19. package/src/lib/components/leads-bulk-bar.js +7 -9
  20. package/src/lib/components/leads-bulk-bar.js.map +1 -1
  21. package/src/lib/components/leads-section.js +5 -1
  22. package/src/lib/components/leads-section.js.map +1 -1
  23. package/src/lib/components/new-lead-drawer.js +5 -4
  24. package/src/lib/components/new-lead-drawer.js.map +1 -1
  25. package/src/lib/components/reports/activity-card.js +4 -3
  26. package/src/lib/components/reports/activity-card.js.map +1 -1
  27. package/src/lib/components/reports/closed-deals-card.js +4 -3
  28. package/src/lib/components/reports/closed-deals-card.js.map +1 -1
  29. package/src/lib/components/reports/contacts-mix-card.js +3 -2
  30. package/src/lib/components/reports/contacts-mix-card.js.map +1 -1
  31. package/src/lib/components/reports/contacts-trend-card.js +2 -1
  32. package/src/lib/components/reports/contacts-trend-card.js.map +1 -1
  33. package/src/lib/components/reports/forecast-card.js +3 -2
  34. package/src/lib/components/reports/forecast-card.js.map +1 -1
  35. package/src/lib/components/reports/lead-funnel-card.js +8 -6
  36. package/src/lib/components/reports/lead-funnel-card.js.map +1 -1
  37. package/src/lib/components/reports/pipeline-card.js +4 -3
  38. package/src/lib/components/reports/pipeline-card.js.map +1 -1
  39. package/src/lib/components/reports/report-format.d.ts +1 -1
  40. package/src/lib/components/reports/report-format.js.map +1 -1
  41. package/src/lib/components/reports/report-scope.d.ts +1 -1
  42. package/src/lib/components/reports/report-scope.js.map +1 -1
  43. package/src/lib/components/reports/source-conversion-card.js +2 -1
  44. package/src/lib/components/reports/source-conversion-card.js.map +1 -1
  45. package/src/lib/components/reports/tasks-card.js +2 -1
  46. package/src/lib/components/reports/tasks-card.js.map +1 -1
  47. package/src/lib/components/reports/won-lost-by-owner-card.js +3 -2
  48. package/src/lib/components/reports/won-lost-by-owner-card.js.map +1 -1
  49. package/src/lib/components/reports-section.js +4 -4
  50. package/src/lib/components/reports-section.js.map +1 -1
  51. package/src/lib/constants/bundle-common.d.ts +2 -0
  52. package/src/lib/constants/bundle-common.js +1 -0
  53. package/src/lib/constants/bundle-common.js.map +1 -1
  54. package/src/lib/declarations.console-server.d.ts +4 -0
  55. package/src/lib/declarations.console-server.js +12 -0
  56. package/src/lib/declarations.console-server.js.map +1 -1
  57. package/src/lib/declarations.server.d.ts +6 -0
  58. package/src/lib/declarations.server.js +51 -1
  59. package/src/lib/declarations.server.js.map +1 -1
  60. package/src/lib/hooks/use-crm-campaigns.d.ts +2 -2
  61. package/src/lib/hooks/use-crm-campaigns.js +3 -3
  62. package/src/lib/hooks/use-crm-campaigns.js.map +1 -1
  63. package/src/lib/model/campaign-filing-activity.d.ts +1 -1
  64. package/src/lib/model/campaign-filing-activity.js +1 -1
  65. package/src/lib/model/campaign-filing-activity.js.map +1 -1
  66. package/src/lib/model/contact-record.js +1 -1
  67. package/src/lib/model/contact-record.js.map +1 -1
  68. package/src/lib/model/crm-digest.d.ts +45 -3
  69. package/src/lib/model/crm-digest.js +75 -11
  70. package/src/lib/model/crm-digest.js.map +1 -1
  71. package/src/lib/model/crm-inbound.d.ts +162 -0
  72. package/src/lib/model/crm-inbound.js +208 -0
  73. package/src/lib/model/crm-inbound.js.map +1 -0
  74. package/src/lib/model/crm-lead-import.d.ts +1 -1
  75. package/src/lib/model/crm-lead-import.js +4 -4
  76. package/src/lib/model/crm-lead-import.js.map +1 -1
  77. package/src/lib/model/crm-reports.d.ts +467 -0
  78. package/src/lib/model/crm-reports.js +667 -0
  79. package/src/lib/model/crm-reports.js.map +1 -0
  80. package/src/lib/model/lead-filters.d.ts +4 -3
  81. package/src/lib/model/lead-filters.js +7 -5
  82. package/src/lib/model/lead-filters.js.map +1 -1
  83. package/src/lib/plugin.js +36 -0
  84. package/src/lib/plugin.js.map +1 -1
  85. package/src/lib/server/api-v1/crm-leads.js +1 -0
  86. package/src/lib/server/api-v1/crm-leads.js.map +1 -1
  87. package/src/lib/server/api-v1/openapi.js +1 -1
  88. package/src/lib/server/api-v1/openapi.js.map +1 -1
  89. package/src/lib/server/campaign-filing-activity.d.ts +1 -1
  90. package/src/lib/server/campaign-filing-activity.js +7 -11
  91. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  92. package/src/lib/server/capture-contact.js +15 -4
  93. package/src/lib/server/capture-contact.js.map +1 -1
  94. package/src/lib/server/contact-email-engagement.d.ts +33 -0
  95. package/src/lib/server/contact-email-engagement.js +146 -0
  96. package/src/lib/server/contact-email-engagement.js.map +1 -0
  97. package/src/lib/server/contact-update.js +3 -3
  98. package/src/lib/server/contact-update.js.map +1 -1
  99. package/src/lib/server/container-detach.d.ts +31 -0
  100. package/src/lib/server/container-detach.js +115 -0
  101. package/src/lib/server/container-detach.js.map +1 -0
  102. package/src/lib/server/crm-inbound-email.d.ts +162 -0
  103. package/src/lib/server/crm-inbound-email.js +259 -0
  104. package/src/lib/server/crm-inbound-email.js.map +1 -0
  105. package/src/lib/server/crm-records-meter.d.ts +50 -0
  106. package/src/lib/server/crm-records-meter.js +126 -0
  107. package/src/lib/server/crm-records-meter.js.map +1 -0
  108. package/src/lib/server/daily-digest-route.d.ts +2 -8
  109. package/src/lib/server/daily-digest-route.js +15 -25
  110. package/src/lib/server/daily-digest-route.js.map +1 -1
  111. package/src/lib/server/inbound-address.js +5 -3
  112. package/src/lib/server/inbound-address.js.map +1 -1
  113. package/src/lib/server/inbound-route.js +7 -4
  114. package/src/lib/server/inbound-route.js.map +1 -1
  115. package/src/lib/server/lead-campaign-carry.js +3 -3
  116. package/src/lib/server/lead-campaign-carry.js.map +1 -1
  117. package/src/lib/server/lead-create.d.ts +1 -1
  118. package/src/lib/server/lead-create.js +10 -15
  119. package/src/lib/server/lead-create.js.map +1 -1
  120. package/src/lib/server/lead-nurturing.d.ts +23 -0
  121. package/src/lib/server/lead-nurturing.js +82 -0
  122. package/src/lib/server/lead-nurturing.js.map +1 -0
  123. package/src/lib/server/leads-import.js +7 -7
  124. package/src/lib/server/leads-import.js.map +1 -1
  125. package/src/lib/server/record-email-state.js +23 -0
  126. package/src/lib/server/record-email-state.js.map +1 -1
  127. package/src/lib/server/record-timeline.d.ts +12 -0
  128. package/src/lib/server/record-timeline.js +127 -15
  129. package/src/lib/server/record-timeline.js.map +1 -1
  130. package/src/lib/server/task-reminders-route.js +4 -2
  131. package/src/lib/server/task-reminders-route.js.map +1 -1
  132. package/src/lib/server/task-routes.d.ts +2 -0
  133. package/src/lib/server/task-routes.js +22 -3
  134. package/src/lib/server/task-routes.js.map +1 -1
  135. package/src/lib/usage-axes.js +8 -1
  136. package/src/lib/usage-axes.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-update.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/contact-update` — one holder's profile fields on one or\n * more contacts (AGL-2804).\n *\n * Body: `{ hostId, contactIds, set }` under a site, or `{ orgId, hostId?,\n * contactIds, set }` at the organization level, where `set` is\n * `ContactUpdateFields`. Answers `{ ok, results }`: one outcome per contact,\n * in the order the ids were sent.\n *\n * ## Why every facet write comes here\n *\n * A contact's fields live in the facet of the holder that keeps them —\n * `facets.{groupId}.*` — and the Firestore rules cannot tell one field of a\n * facet from another: the holder is a map key a rule cannot address, and a\n * write's affected keys name only `facets`. So the rules let a client update\n * change nothing in a facet beyond letting a holder go, and the console's\n * edits arrive here, where the plan is asked about the fields the request\n * actually carries.\n *\n * ## Who may call it\n *\n * The CRM writer the task routes admit (`authorizeCrmWriter`): under a site,\n * a member whose role writes org data, who holds `data.manage` and whose\n * access reaches the site; at the organization level, an org-wide member\n * with the permission. Each contact must then be one the caller may read —\n * the rules' `canReadScoped()` — and, under a site, one the site's hub lists.\n *\n * ## The plan\n *\n * Every field is the CRM's (AGL-2790), and a plan without the CRM has none\n * of it (AGL-2851), so it edits no contact: not the owner, company, custom\n * values or files, and not the profile a capture writes (name, phone, job\n * title, address), the tags, the notes or the campaign filing either. A\n * request carrying any field is refused to such a plan once the caller is\n * known, and before a contact is read — staff included, because the plan is\n * a fact about the workspace. Erasing a person is another route, open on\n * every plan.\n *\n * ## One holder, by dotted path\n *\n * Under a site the facet is the site's consent group's; at the organization\n * level it is each contact's own primary holder, the facet the record page\n * shows there. Every field is one dotted path — a nested `facets` object\n * would replace every other holder's map — and a text field sent empty is\n * deleted rather than stored blank, so \"no phone\" has one shape.\n *\n * ## Many contacts, one commit\n *\n * The contacts are read together and written in one batch. A batch that\n * fails is retried contact by contact, so the one that could not be written\n * is named and the rest still land. A company's contacts count follows the\n * link: summed per company and settled once the contacts are written, so a\n * company deleted in between cannot fail the save of the people at it — the\n * company page's live aggregate is what corrects a count that did not move.\n */\n\nimport {\n type AglynPostalAddress,\n consentGroupForHost,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n type ContactFieldDefinition,\n campaignMembershipValue,\n contactCampaignFieldPath,\n contactFacetPath,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_FIELD,\n crmReadTokens,\n effectiveCrmLeadSourcePicklist,\n isOrgWideMember,\n judgeCrmLeadSource,\n normalizeAddress,\n normalizeCrmMediaIds,\n normalizeCrmPicklistLabel,\n normalizePhone,\n type PluginApiHandler,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactFacet,\n readCrmCustomInput,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n companyContactsCountFields,\n contactCompanyLinkFields,\n firebaseAdmin,\n resolveOrgMembership,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONTACT_UPDATE_FIELDS,\n type ContactUpdateFields,\n type ContactUpdateOutcome,\n type ContactUpdateResponse,\n CRM_CONTACT_UPDATE_MAX,\n} from '../model/contact-update'\nimport {\n CONTACT_NOTES_MAX,\n CONTACT_PHONE_REFUSAL,\n CONTACT_TAGS_MAX,\n normalizeTags,\n readBulkTag,\n typed,\n} from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/** What the suite gate names for each field a request can carry. */\nconst SUITE_ACTS: Record<keyof ContactUpdateFields, string> = {\n name: \"Editing a contact's profile\",\n phone: \"Editing a contact's profile\",\n jobTitle: \"Editing a contact's profile\",\n leadSource: \"Editing a contact's profile\",\n address: \"Editing a contact's profile\",\n notes: \"Editing a contact's notes\",\n tags: 'Tagging a contact',\n addTag: 'Tagging a contact',\n removeTag: 'Tagging a contact',\n campaignIds: 'Filing a contact under a campaign',\n ownerUid: \"Assigning a contact's owner\",\n companyId: 'Filing a contact under a company',\n companyName: 'Filing a contact under a company',\n custom: \"Editing a contact's custom fields\",\n mediaIds: 'Attaching files to a contact',\n}\n\n/** A contact read at the organization level that no site has captured. */\nconst NO_HOLDER_REFUSAL = 'No site holds this contact yet, so it has no profile to edit.'\n\n/**\n * The act a request is refused for on a plan without the CRM: the first\n * field it carries, in the order the route reads them. Every field is the\n * CRM's, so whichever field a request names is the act its refusal names.\n */\nexport function contactUpdateSuiteAct(fields: ContactUpdateFields): string {\n const field = CONTACT_UPDATE_FIELDS.find((key) => fields[key] !== undefined)\n return field ? SUITE_ACTS[field] : \"Editing a contact's profile\"\n}\n\n/**\n * A body's `set`, read into the fields it names, or the sentence refusing\n * it. A key the route does not save is refused rather than dropped: a save\n * that silently lost a field would read as a save that kept it.\n */\nexport function readContactUpdateFields(\n input: unknown,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, error: 'Name the fields to save.' }\n }\n const raw = input as Record<string, unknown>\n const unknownKey = Object.keys(raw).find(\n (key) => !(CONTACT_UPDATE_FIELDS as readonly string[]).includes(key),\n )\n if (unknownKey) {\n return { ok: false, error: `\"${unknownKey}\" is not a field a contact's profile saves.` }\n }\n const fields: ContactUpdateFields = {}\n if ('name' in raw) fields.name = typed(raw['name'], 120)\n if ('phone' in raw) {\n const text = typed(raw['phone'], 40)\n const phone = text ? normalizePhone(text) : ''\n if (phone === null) return { ok: false, error: CONTACT_PHONE_REFUSAL }\n fields.phone = phone\n }\n if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120)\n // Held to the label's shape here; judged against the org's list by the\n // handler, which alone can read it (AGL-3298).\n if ('leadSource' in raw) fields.leadSource = normalizeCrmPicklistLabel(raw['leadSource'])\n if ('address' in raw) {\n const address = raw['address']\n fields.address =\n address && typeof address === 'object' && !Array.isArray(address)\n ? normalizeAddress(address as AglynPostalAddress)\n : null\n }\n if ('notes' in raw) fields.notes = String(raw['notes'] ?? '').slice(0, CONTACT_NOTES_MAX)\n // One write may carry one transform per field, so the tags are replaced,\n // added to or taken from — never two of those at once.\n if (['tags', 'addTag', 'removeTag'].filter((key) => key in raw).length > 1) {\n return { ok: false, error: 'Send the tags, a tag to add or a tag to remove — one of them.' }\n }\n if ('tags' in raw) {\n if (!Array.isArray(raw['tags'])) return { ok: false, error: 'Tags must be a list.' }\n fields.tags = normalizeTags(raw['tags'])\n }\n for (const key of ['addTag', 'removeTag'] as const) {\n if (!(key in raw)) continue\n const tag = readBulkTag(raw[key])\n if (!tag) return { ok: false, error: 'Name the tag.' }\n fields[key] = tag\n }\n if ('campaignIds' in raw) {\n const ids = raw['campaignIds']\n if (!Array.isArray(ids)) return { ok: false, error: 'Campaigns must be a list.' }\n fields.campaignIds = campaignMembershipValue(ids.map((id) => String(id ?? '')))\n }\n if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128)\n if ('companyId' in raw) {\n const companyId = raw['companyId'] === null ? null : typed(raw['companyId'], 128)\n if (companyId !== null && (!companyId || companyId.includes('/'))) {\n return { ok: false, error: 'The company could not be read.' }\n }\n fields.companyId = companyId\n }\n if ('companyName' in raw) fields.companyName = typed(raw['companyName'], 120)\n if ('custom' in raw) {\n const custom = raw['custom']\n if (!custom || typeof custom !== 'object' || Array.isArray(custom)) {\n return { ok: false, error: 'Custom fields must be values keyed by field key.' }\n }\n fields.custom = custom as Record<string, ContactCustomValue>\n }\n if ('mediaIds' in raw) {\n if (!Array.isArray(raw['mediaIds'])) {\n return { ok: false, error: 'Files must be a list of ids.' }\n }\n fields.mediaIds = normalizeCrmMediaIds(raw['mediaIds'])\n }\n if (!Object.keys(fields).length) return { ok: false, error: 'Name the fields to save.' }\n return { ok: true, fields }\n}\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_UPDATE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/**\n * Under a site, whether the caller's access reaches it. The rules could not\n * ask this of a facet write — they cannot see which holder a write is for —\n * and a collaborator on one site must not write another site's notes on a\n * person both of them hold.\n */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\nconst refused = (contactId: string, error: string): ContactUpdateOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\ninterface ContactWrite {\n contactId: string\n ref: FirebaseFirestore.DocumentReference\n update: Record<string, unknown>\n counts: ContactCompanyLinkPlan['counts']\n}\n\n/**\n * One contact's update for one holder, or the sentence refusing the row.\n * `update` is `null` when the document already says everything asked — a tag\n * the row already carries, a link already made.\n */\nfunction contactPatch(\n contact: Record<string, unknown>,\n groupId: string,\n fields: ContactUpdateFields,\n custom: Record<string, ContactCustomValue> | undefined,\n linkedCompanyName: string,\n):\n | { update: Record<string, unknown> | null; counts: ContactCompanyLinkPlan['counts'] }\n | { refused: string } {\n const path = (field: string) => contactFacetPath(groupId, field)\n /** A text field as stored: the value, or the field's absence. */\n const text = (value: string) => value || FieldValue.delete()\n const update: Record<string, unknown> = {}\n let counts: ContactCompanyLinkPlan['counts'] = []\n\n if (fields.name !== undefined) update[path('name')] = text(fields.name)\n if (fields.phone !== undefined) {\n update[path('phone')] = text(fields.phone)\n // The search echo — see `HostContact.phone`.\n update['phone'] = text(fields.phone)\n }\n if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle)\n if (fields.leadSource !== undefined) update[path('leadSource')] = text(fields.leadSource)\n if (fields.address !== undefined) {\n update[path('address')] = fields.address ?? FieldValue.delete()\n }\n if (fields.notes !== undefined) update[path('notes')] = fields.notes\n if (fields.tags !== undefined) update[path('tags')] = fields.tags\n\n const held = (readContactFacet(contact, groupId).tags ?? []).map((tag) =>\n String(tag).toLowerCase(),\n )\n if (fields.addTag && !held.includes(fields.addTag)) {\n // The record page's cap, which a selection must not slip past.\n if (held.length >= CONTACT_TAGS_MAX) {\n return { refused: `already has ${CONTACT_TAGS_MAX} tags` }\n }\n update[path('tags')] = FieldValue.arrayUnion(fields.addTag)\n }\n if (fields.removeTag && held.includes(fields.removeTag)) {\n update[path('tags')] = FieldValue.arrayRemove(fields.removeTag)\n }\n if (fields.campaignIds !== undefined) {\n update[contactCampaignFieldPath(groupId)] = fields.campaignIds\n }\n if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid)\n\n if (fields.companyId !== undefined) {\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n fields.companyId,\n )\n if (plan) {\n Object.assign(update, contactCompanyLinkFields(plan, groupId))\n counts = plan.counts\n if (fields.companyName === undefined) {\n // The label follows the link: the company's own name, or nothing.\n const label = plan.companyId ? linkedCompanyName : ''\n update[path('companyName')] = text(label)\n // The search echo — see `HostContact.companyName`.\n update['companyName'] = text(label)\n }\n }\n }\n if (fields.companyName !== undefined) {\n update[path('companyName')] = text(fields.companyName)\n update['companyName'] = text(fields.companyName)\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[path(`custom.${key}`)] = value\n }\n if (fields.mediaIds !== undefined) update[path(CRM_MEDIA_IDS_FIELD)] = fields.mediaIds\n\n if (!Object.keys(update).length) return { update: null, counts }\n update['updatedAt'] = FieldValue.serverTimestamp()\n return { update, counts }\n}\n\n/**\n * Every write in one batch, and — when the batch is refused — each on its\n * own, so the contact that could not be written is the only one reported.\n * Answers the ids that were not written.\n */\nasync function commitContactWrites(\n firestore: FirebaseFirestore.Firestore,\n writes: readonly ContactWrite[],\n): Promise<Set<string>> {\n const failed = new Set<string>()\n if (!writes.length) return failed\n const batch = firestore.batch()\n for (const write of writes) batch.update(write.ref, write.update)\n let committed = false\n try {\n await batch.commit()\n committed = true\n } catch {\n // A batch names no document; the pass below finds the one it was.\n }\n if (!committed) {\n for (const write of writes) {\n try {\n await write.ref.update(write.update)\n } catch (error) {\n console.error('[crm] contact-update could not write a contact', write.contactId, error)\n failed.add(write.contactId)\n }\n }\n }\n // The owner, tags, company and custom values are what the Contacts list\n // filters by through `facetKeys` (AGL-3321): restamped from each row.\n await restampCrmListFieldsOf(\n writes.filter((write) => !failed.has(write.contactId)).map((write) => write.ref),\n 'contacts',\n )\n return failed\n}\n\n/** Each company's contacts count moved once, by the sum of what the written links moved. */\nasync function settleCompanyCounts(\n companies: FirebaseFirestore.CollectionReference,\n writes: readonly ContactWrite[],\n): Promise<void> {\n const deltas = new Map<string, number>()\n for (const write of writes) {\n for (const count of write.counts) {\n deltas.set(count.companyId, (deltas.get(count.companyId) ?? 0) + count.delta)\n }\n }\n await Promise.all(\n [...deltas]\n .filter(([, delta]) => delta !== 0)\n .map(([companyId, delta]) =>\n companies\n .doc(companyId)\n .update(companyContactsCountFields(delta))\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', companyId, delta, error)\n }),\n ),\n )\n}\n\nexport const crmContactUpdateHandler: 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 body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_UPDATE_MAX} contacts.` })\n return\n }\n const read = readContactUpdateFields(body['set'])\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const { fields } = read\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n // The plan after the person, and before any contact is read (AGL-2787).\n const suite = crmSuiteRefusal(writer.org, contactUpdateSuiteAct(fields))\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n\n let custom: Record<string, ContactCustomValue> | undefined\n if (fields.custom) {\n const definitions = await orgRef\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n fields.custom,\n definitions.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'contact',\n )\n if ('errors' in judged) {\n res.status(400).json({\n error: Object.values(judged.errors)[0] ?? 'A custom value could not be saved.',\n })\n return\n }\n custom = judged.values\n }\n\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), read only when the body names one.\n * Judged per contact below, because a contact keeps the value it\n * already holds even when the list has since deactivated it.\n */\n const leadSources = fields.leadSource\n ? effectiveCrmLeadSourcePicklist(\n (\n await orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get()\n ).data(),\n )\n : null\n\n if (fields.ownerUid) {\n const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(() => null)\n if (!owner?.member) {\n res.status(400).json({ error: 'The owner is not a member of this organization.' })\n return\n }\n }\n\n // The company a link names has to exist and be visible to the caller and\n // the site, or a person could be filed under a record nobody here can open.\n let linkedCompanyName = ''\n if (fields.companyId) {\n const company = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(fields.companyId)\n .get()\n const tokens = company.exists ? (company.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n company.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n linkedCompanyName = typed(company.get('name'), 120)\n }\n\n const contacts = orgRef.collection('contacts')\n const snapshots = await firestore.getAll(...contactIds.map((id) => contacts.doc(id)))\n const outcomes = new Map<string, ContactUpdateOutcome>()\n const writes: ContactWrite[] = []\n snapshots.forEach((snapshot, index) => {\n const contactId = contactIds[index]\n if (!snapshot.exists) {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n return\n }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const visibleTo = data['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to you.'))\n return\n }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'))\n return\n }\n const group = siteGroup ?? contactPrimaryGroup(data, writer.org)\n if (!group.groupId) {\n outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL))\n return\n }\n let rowFields = fields\n if (leadSources && fields.leadSource) {\n const judged = judgeCrmLeadSource(\n leadSources,\n fields.leadSource,\n readContactFacet(data, group.groupId).leadSource,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n return\n }\n rowFields = { ...fields, leadSource: judged.value ?? '' }\n }\n const patch = contactPatch(data, group.groupId, rowFields, custom, linkedCompanyName)\n if ('refused' in patch) {\n outcomes.set(contactId, refused(contactId, patch.refused))\n return\n }\n outcomes.set(contactId, { contactId, ok: true })\n if (patch.update) {\n writes.push({ contactId, ref: snapshot.ref, update: patch.update, counts: patch.counts })\n }\n })\n\n const failed = await commitContactWrites(firestore, writes)\n for (const contactId of failed) {\n outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'))\n }\n await settleCompanyCounts(\n orgRef.collection(CRM_COLLECTIONS.companies),\n writes.filter((write) => !failed.has(write.contactId)),\n )\n\n const answer: ContactUpdateResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) => outcomes.get(contactId) ?? refused(contactId, 'The contact could not be saved.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-update failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n"],"names":["consentGroupForHost","CONTACT_FIELDS_MAX_PER_ORG","campaignMembershipValue","contactCampaignFieldPath","contactFacetPath","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_FIELD","crmReadTokens","effectiveCrmLeadSourcePicklist","isOrgWideMember","judgeCrmLeadSource","normalizeAddress","normalizeCrmMediaIds","normalizeCrmPicklistLabel","normalizePhone","planContactCompanyLink","readContactCompanyLink","readContactFacet","readCrmCustomInput","visibleToTokens","companyContactsCountFields","contactCompanyLinkFields","firebaseAdmin","resolveOrgMembership","restampCrmListFieldsOf","FieldValue","CONTACT_UPDATE_FIELDS","CRM_CONTACT_UPDATE_MAX","CONTACT_NOTES_MAX","CONTACT_PHONE_REFUSAL","CONTACT_TAGS_MAX","normalizeTags","readBulkTag","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","SUITE_ACTS","name","phone","jobTitle","leadSource","address","notes","tags","addTag","removeTag","campaignIds","ownerUid","companyId","companyName","custom","mediaIds","NO_HOLDER_REFUSAL","contactUpdateSuiteAct","fields","field","find","key","undefined","readContactUpdateFields","input","raw","Array","isArray","ok","error","unknownKey","Object","keys","includes","text","String","slice","filter","length","tag","ids","map","id","readContactIds","value","some","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","refused","contactId","contactPatch","contact","groupId","linkedCompanyName","path","delete","update","counts","held","toLowerCase","arrayUnion","arrayRemove","plan","assign","label","entries","serverTimestamp","commitContactWrites","firestore","writes","failed","batch","write","ref","committed","commit","console","add","has","settleCompanyCounts","companies","deltas","Map","count","set","get","delta","Promise","all","doc","catch","crmContactUpdateHandler","req","res","method","setHeader","status","json","body","scope","contactIds","read","suiteAct","level","suite","org","app","orgRef","collection","orgId","siteGroup","siteTokens","definitions","contactFields","limit","judged","docs","data","values","errors","leadSources","picklists","owner","company","tokens","exists","visible","contacts","snapshots","getAll","outcomes","forEach","snapshot","index","visibleTo","group","rowFields","patch","push","answer","results"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/contact-update.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/contact-update` — one holder's profile fields on one or\n * more contacts (AGL-2804).\n *\n * Body: `{ hostId, contactIds, set }` under a site, or `{ orgId, hostId?,\n * contactIds, set }` at the organization level, where `set` is\n * `ContactUpdateFields`. Answers `{ ok, results }`: one outcome per contact,\n * in the order the ids were sent.\n *\n * ## Why every facet write comes here\n *\n * A contact's fields live in the facet of the holder that keeps them —\n * `facets.{groupId}.*` — and the Firestore rules cannot tell one field of a\n * facet from another: the holder is a map key a rule cannot address, and a\n * write's affected keys name only `facets`. So the rules let a client update\n * change nothing in a facet beyond letting a holder go, and the console's\n * edits arrive here, where the plan is asked about the fields the request\n * actually carries.\n *\n * ## Who may call it\n *\n * The CRM writer the task routes admit (`authorizeCrmWriter`): under a site,\n * a member whose role writes org data, who holds `data.manage` and whose\n * access reaches the site; at the organization level, an org-wide member\n * with the permission. Each contact must then be one the caller may read —\n * the rules' `canReadScoped()` — and, under a site, one the site's hub lists.\n *\n * ## The plan\n *\n * Every field is the CRM's (AGL-2790), and a plan without the CRM has none\n * of it (AGL-2851), so it edits no contact: not the owner, company, custom\n * values or files, and not the profile a capture writes (name, phone, job\n * title, address), the tags, the notes or the campaign filing either. A\n * request carrying any field is refused to such a plan once the caller is\n * known, and before a contact is read — staff included, because the plan is\n * a fact about the workspace. Erasing a person is another route, open on\n * every plan.\n *\n * ## One holder, by dotted path\n *\n * Under a site the facet is the site's consent group's; at the organization\n * level it is each contact's own primary holder, the facet the record page\n * shows there. Every field is one dotted path — a nested `facets` object\n * would replace every other holder's map — and a text field sent empty is\n * deleted rather than stored blank, so \"no phone\" has one shape.\n *\n * ## Many contacts, one commit\n *\n * The contacts are read together and written in one batch. A batch that\n * fails is retried contact by contact, so the one that could not be written\n * is named and the rest still land. A company's contacts count follows the\n * link: summed per company and settled once the contacts are written, so a\n * company deleted in between cannot fail the save of the people at it — the\n * company page's live aggregate is what corrects a count that did not move.\n */\n\nimport {\n type AglynPostalAddress,\n consentGroupForHost,\n CONTACT_FIELDS_MAX_PER_ORG,\n type ContactCompanyLinkPlan,\n type ContactCustomValue,\n type ContactFieldDefinition,\n containerMembershipValue,\n contactContainerFieldPath,\n contactFacetPath,\n CRM_COLLECTIONS,\n CRM_LEAD_SOURCE_PICKLIST,\n CRM_MEDIA_IDS_FIELD,\n crmReadTokens,\n effectiveCrmLeadSourcePicklist,\n isOrgWideMember,\n judgeCrmLeadSource,\n normalizeAddress,\n normalizeCrmMediaIds,\n normalizeCrmPicklistLabel,\n normalizePhone,\n type PluginApiHandler,\n planContactCompanyLink,\n readContactCompanyLink,\n readContactFacet,\n readCrmCustomInput,\n visibleToTokens,\n} from '@aglyn/aglyn/server'\nimport {\n companyContactsCountFields,\n contactCompanyLinkFields,\n firebaseAdmin,\n resolveOrgMembership,\n restampCrmListFieldsOf,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport {\n CONTACT_UPDATE_FIELDS,\n type ContactUpdateFields,\n type ContactUpdateOutcome,\n type ContactUpdateResponse,\n CRM_CONTACT_UPDATE_MAX,\n} from '../model/contact-update'\nimport {\n CONTACT_NOTES_MAX,\n CONTACT_PHONE_REFUSAL,\n CONTACT_TAGS_MAX,\n normalizeTags,\n readBulkTag,\n typed,\n} from './contact-profile'\nimport { readCrmRouteScope } from './org-caller'\nimport { crmSuiteRefusal } from './suite-gate'\nimport { authorizeCrmWriter, canReach, type Writer } from './task-routes'\nimport { contactPrimaryGroup } from '../model/contact-holder'\n\n/** What the suite gate names for each field a request can carry. */\nconst SUITE_ACTS: Record<keyof ContactUpdateFields, string> = {\n name: \"Editing a contact's profile\",\n phone: \"Editing a contact's profile\",\n jobTitle: \"Editing a contact's profile\",\n leadSource: \"Editing a contact's profile\",\n address: \"Editing a contact's profile\",\n notes: \"Editing a contact's notes\",\n tags: 'Tagging a contact',\n addTag: 'Tagging a contact',\n removeTag: 'Tagging a contact',\n campaignIds: 'Filing a contact under a campaign',\n ownerUid: \"Assigning a contact's owner\",\n companyId: 'Filing a contact under a company',\n companyName: 'Filing a contact under a company',\n custom: \"Editing a contact's custom fields\",\n mediaIds: 'Attaching files to a contact',\n}\n\n/** A contact read at the organization level that no site has captured. */\nconst NO_HOLDER_REFUSAL = 'No site holds this contact yet, so it has no profile to edit.'\n\n/**\n * The act a request is refused for on a plan without the CRM: the first\n * field it carries, in the order the route reads them. Every field is the\n * CRM's, so whichever field a request names is the act its refusal names.\n */\nexport function contactUpdateSuiteAct(fields: ContactUpdateFields): string {\n const field = CONTACT_UPDATE_FIELDS.find((key) => fields[key] !== undefined)\n return field ? SUITE_ACTS[field] : \"Editing a contact's profile\"\n}\n\n/**\n * A body's `set`, read into the fields it names, or the sentence refusing\n * it. A key the route does not save is refused rather than dropped: a save\n * that silently lost a field would read as a save that kept it.\n */\nexport function readContactUpdateFields(\n input: unknown,\n): { ok: true; fields: ContactUpdateFields } | { ok: false; error: string } {\n if (!input || typeof input !== 'object' || Array.isArray(input)) {\n return { ok: false, error: 'Name the fields to save.' }\n }\n const raw = input as Record<string, unknown>\n const unknownKey = Object.keys(raw).find(\n (key) => !(CONTACT_UPDATE_FIELDS as readonly string[]).includes(key),\n )\n if (unknownKey) {\n return { ok: false, error: `\"${unknownKey}\" is not a field a contact's profile saves.` }\n }\n const fields: ContactUpdateFields = {}\n if ('name' in raw) fields.name = typed(raw['name'], 120)\n if ('phone' in raw) {\n const text = typed(raw['phone'], 40)\n const phone = text ? normalizePhone(text) : ''\n if (phone === null) return { ok: false, error: CONTACT_PHONE_REFUSAL }\n fields.phone = phone\n }\n if ('jobTitle' in raw) fields.jobTitle = typed(raw['jobTitle'], 120)\n // Held to the label's shape here; judged against the org's list by the\n // handler, which alone can read it (AGL-3298).\n if ('leadSource' in raw) fields.leadSource = normalizeCrmPicklistLabel(raw['leadSource'])\n if ('address' in raw) {\n const address = raw['address']\n fields.address =\n address && typeof address === 'object' && !Array.isArray(address)\n ? normalizeAddress(address as AglynPostalAddress)\n : null\n }\n if ('notes' in raw) fields.notes = String(raw['notes'] ?? '').slice(0, CONTACT_NOTES_MAX)\n // One write may carry one transform per field, so the tags are replaced,\n // added to or taken from — never two of those at once.\n if (['tags', 'addTag', 'removeTag'].filter((key) => key in raw).length > 1) {\n return { ok: false, error: 'Send the tags, a tag to add or a tag to remove — one of them.' }\n }\n if ('tags' in raw) {\n if (!Array.isArray(raw['tags'])) return { ok: false, error: 'Tags must be a list.' }\n fields.tags = normalizeTags(raw['tags'])\n }\n for (const key of ['addTag', 'removeTag'] as const) {\n if (!(key in raw)) continue\n const tag = readBulkTag(raw[key])\n if (!tag) return { ok: false, error: 'Name the tag.' }\n fields[key] = tag\n }\n if ('campaignIds' in raw) {\n const ids = raw['campaignIds']\n if (!Array.isArray(ids)) return { ok: false, error: 'Campaigns must be a list.' }\n fields.campaignIds = containerMembershipValue(ids.map((id) => String(id ?? '')))\n }\n if ('ownerUid' in raw) fields.ownerUid = typed(raw['ownerUid'], 128)\n if ('companyId' in raw) {\n const companyId = raw['companyId'] === null ? null : typed(raw['companyId'], 128)\n if (companyId !== null && (!companyId || companyId.includes('/'))) {\n return { ok: false, error: 'The company could not be read.' }\n }\n fields.companyId = companyId\n }\n if ('companyName' in raw) fields.companyName = typed(raw['companyName'], 120)\n if ('custom' in raw) {\n const custom = raw['custom']\n if (!custom || typeof custom !== 'object' || Array.isArray(custom)) {\n return { ok: false, error: 'Custom fields must be values keyed by field key.' }\n }\n fields.custom = custom as Record<string, ContactCustomValue>\n }\n if ('mediaIds' in raw) {\n if (!Array.isArray(raw['mediaIds'])) {\n return { ok: false, error: 'Files must be a list of ids.' }\n }\n fields.mediaIds = normalizeCrmMediaIds(raw['mediaIds'])\n }\n if (!Object.keys(fields).length) return { ok: false, error: 'Name the fields to save.' }\n return { ok: true, fields }\n}\n\n/** The ids a body names, deduplicated — or `null` when any is unreadable or there are too many. */\nfunction readContactIds(value: unknown): string[] | null {\n if (!Array.isArray(value) || !value.length || value.length > CRM_CONTACT_UPDATE_MAX) {\n return null\n }\n const ids = value.map((id) => typed(id, 200))\n if (ids.some((id) => !id || id.includes('/'))) return null\n return [...new Set(ids)]\n}\n\n/**\n * Under a site, whether the caller's access reaches it. The rules could not\n * ask this of a facet write — they cannot see which holder a write is for —\n * and a collaborator on one site must not write another site's notes on a\n * person both of them hold.\n */\nfunction reachesSite(writer: Writer, hostId: string): boolean {\n return (\n writer.staff ||\n isOrgWideMember(writer.member) ||\n Boolean((writer.member?.hostAccess as Record<string, unknown> | undefined)?.[hostId])\n )\n}\n\nconst refused = (contactId: string, error: string): ContactUpdateOutcome => ({\n contactId,\n ok: false,\n error,\n})\n\ninterface ContactWrite {\n contactId: string\n ref: FirebaseFirestore.DocumentReference\n update: Record<string, unknown>\n counts: ContactCompanyLinkPlan['counts']\n}\n\n/**\n * One contact's update for one holder, or the sentence refusing the row.\n * `update` is `null` when the document already says everything asked — a tag\n * the row already carries, a link already made.\n */\nfunction contactPatch(\n contact: Record<string, unknown>,\n groupId: string,\n fields: ContactUpdateFields,\n custom: Record<string, ContactCustomValue> | undefined,\n linkedCompanyName: string,\n):\n | { update: Record<string, unknown> | null; counts: ContactCompanyLinkPlan['counts'] }\n | { refused: string } {\n const path = (field: string) => contactFacetPath(groupId, field)\n /** A text field as stored: the value, or the field's absence. */\n const text = (value: string) => value || FieldValue.delete()\n const update: Record<string, unknown> = {}\n let counts: ContactCompanyLinkPlan['counts'] = []\n\n if (fields.name !== undefined) update[path('name')] = text(fields.name)\n if (fields.phone !== undefined) {\n update[path('phone')] = text(fields.phone)\n // The search echo — see `HostContact.phone`.\n update['phone'] = text(fields.phone)\n }\n if (fields.jobTitle !== undefined) update[path('jobTitle')] = text(fields.jobTitle)\n if (fields.leadSource !== undefined) update[path('leadSource')] = text(fields.leadSource)\n if (fields.address !== undefined) {\n update[path('address')] = fields.address ?? FieldValue.delete()\n }\n if (fields.notes !== undefined) update[path('notes')] = fields.notes\n if (fields.tags !== undefined) update[path('tags')] = fields.tags\n\n const held = (readContactFacet(contact, groupId).tags ?? []).map((tag) =>\n String(tag).toLowerCase(),\n )\n if (fields.addTag && !held.includes(fields.addTag)) {\n // The record page's cap, which a selection must not slip past.\n if (held.length >= CONTACT_TAGS_MAX) {\n return { refused: `already has ${CONTACT_TAGS_MAX} tags` }\n }\n update[path('tags')] = FieldValue.arrayUnion(fields.addTag)\n }\n if (fields.removeTag && held.includes(fields.removeTag)) {\n update[path('tags')] = FieldValue.arrayRemove(fields.removeTag)\n }\n if (fields.campaignIds !== undefined) {\n update[contactContainerFieldPath(groupId, 'campaign')] = fields.campaignIds\n }\n if (fields.ownerUid !== undefined) update[path('ownerUid')] = text(fields.ownerUid)\n\n if (fields.companyId !== undefined) {\n const plan = planContactCompanyLink(\n readContactCompanyLink(contact, groupId),\n fields.companyId,\n )\n if (plan) {\n Object.assign(update, contactCompanyLinkFields(plan, groupId))\n counts = plan.counts\n if (fields.companyName === undefined) {\n // The label follows the link: the company's own name, or nothing.\n const label = plan.companyId ? linkedCompanyName : ''\n update[path('companyName')] = text(label)\n // The search echo — see `HostContact.companyName`.\n update['companyName'] = text(label)\n }\n }\n }\n if (fields.companyName !== undefined) {\n update[path('companyName')] = text(fields.companyName)\n update['companyName'] = text(fields.companyName)\n }\n for (const [key, value] of Object.entries(custom ?? {})) {\n update[path(`custom.${key}`)] = value\n }\n if (fields.mediaIds !== undefined) update[path(CRM_MEDIA_IDS_FIELD)] = fields.mediaIds\n\n if (!Object.keys(update).length) return { update: null, counts }\n update['updatedAt'] = FieldValue.serverTimestamp()\n return { update, counts }\n}\n\n/**\n * Every write in one batch, and — when the batch is refused — each on its\n * own, so the contact that could not be written is the only one reported.\n * Answers the ids that were not written.\n */\nasync function commitContactWrites(\n firestore: FirebaseFirestore.Firestore,\n writes: readonly ContactWrite[],\n): Promise<Set<string>> {\n const failed = new Set<string>()\n if (!writes.length) return failed\n const batch = firestore.batch()\n for (const write of writes) batch.update(write.ref, write.update)\n let committed = false\n try {\n await batch.commit()\n committed = true\n } catch {\n // A batch names no document; the pass below finds the one it was.\n }\n if (!committed) {\n for (const write of writes) {\n try {\n await write.ref.update(write.update)\n } catch (error) {\n console.error('[crm] contact-update could not write a contact', write.contactId, error)\n failed.add(write.contactId)\n }\n }\n }\n // The owner, tags, company and custom values are what the Contacts list\n // filters by through `facetKeys` (AGL-3321): restamped from each row.\n await restampCrmListFieldsOf(\n writes.filter((write) => !failed.has(write.contactId)).map((write) => write.ref),\n 'contacts',\n )\n return failed\n}\n\n/** Each company's contacts count moved once, by the sum of what the written links moved. */\nasync function settleCompanyCounts(\n companies: FirebaseFirestore.CollectionReference,\n writes: readonly ContactWrite[],\n): Promise<void> {\n const deltas = new Map<string, number>()\n for (const write of writes) {\n for (const count of write.counts) {\n deltas.set(count.companyId, (deltas.get(count.companyId) ?? 0) + count.delta)\n }\n }\n await Promise.all(\n [...deltas]\n .filter(([, delta]) => delta !== 0)\n .map(([companyId, delta]) =>\n companies\n .doc(companyId)\n .update(companyContactsCountFields(delta))\n .catch((error: unknown) => {\n console.error('[crm] company contacts count could not move', companyId, delta, error)\n }),\n ),\n )\n}\n\nexport const crmContactUpdateHandler: 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 body = (req.body ?? {}) as Record<string, unknown>\n const scope = readCrmRouteScope(body)\n if (!scope) {\n res.status(400).json({ error: 'Missing hostId' })\n return\n }\n const contactIds = readContactIds(body['contactIds'])\n if (!contactIds) {\n res.status(400).json({ error: `Name between 1 and ${CRM_CONTACT_UPDATE_MAX} contacts.` })\n return\n }\n const read = readContactUpdateFields(body['set'])\n if (read.ok === false) {\n res.status(400).json({ error: read.error })\n return\n }\n const { fields } = read\n\n try {\n const writer = await authorizeCrmWriter(req, scope, { suiteAct: null })\n if (writer.ok === false) {\n res.status(writer.status).json(writer.body)\n return\n }\n if (scope.level === 'site' && !reachesSite(writer, scope.hostId)) {\n res.status(403).json({ error: 'Your access to this organization does not reach this site.' })\n return\n }\n // The plan after the person, and before any contact is read (AGL-2787).\n const suite = crmSuiteRefusal(writer.org, contactUpdateSuiteAct(fields))\n if (suite) {\n res.status(suite.status).json(suite.body)\n return\n }\n\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(writer.orgId)\n const siteGroup =\n scope.level === 'site' ? consentGroupForHost(writer.org, scope.hostId) : null\n const siteTokens = siteGroup ? crmReadTokens(siteGroup) : null\n\n let custom: Record<string, ContactCustomValue> | undefined\n if (fields.custom) {\n const definitions = await orgRef\n .collection(CRM_COLLECTIONS.contactFields)\n .limit(CONTACT_FIELDS_MAX_PER_ORG)\n .get()\n const judged = readCrmCustomInput(\n fields.custom,\n definitions.docs.map((doc) => doc.data() as ContactFieldDefinition),\n 'contact',\n )\n if ('errors' in judged) {\n res.status(400).json({\n error: Object.values(judged.errors)[0] ?? 'A custom value could not be saved.',\n })\n return\n }\n custom = judged.values\n }\n\n /*\n * THE ORG'S LEAD SOURCES (AGL-3298), read only when the body names one.\n * Judged per contact below, because a contact keeps the value it\n * already holds even when the list has since deactivated it.\n */\n const leadSources = fields.leadSource\n ? effectiveCrmLeadSourcePicklist(\n (\n await orgRef.collection(CRM_COLLECTIONS.picklists).doc(CRM_LEAD_SOURCE_PICKLIST).get()\n ).data(),\n )\n : null\n\n if (fields.ownerUid) {\n const owner = await resolveOrgMembership(fields.ownerUid, writer.orgId).catch(() => null)\n if (!owner?.member) {\n res.status(400).json({ error: 'The owner is not a member of this organization.' })\n return\n }\n }\n\n // The company a link names has to exist and be visible to the caller and\n // the site, or a person could be filed under a record nobody here can open.\n let linkedCompanyName = ''\n if (fields.companyId) {\n const company = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(fields.companyId)\n .get()\n const tokens = company.exists ? (company.get('visibleTo') as string[] | undefined) : undefined\n const visible =\n company.exists &&\n canReach(writer, tokens) &&\n (!siteTokens || visibleToTokens(tokens, siteTokens))\n if (!visible) {\n res.status(404).json({ error: 'Unknown company' })\n return\n }\n linkedCompanyName = typed(company.get('name'), 120)\n }\n\n const contacts = orgRef.collection('contacts')\n const snapshots = await firestore.getAll(...contactIds.map((id) => contacts.doc(id)))\n const outcomes = new Map<string, ContactUpdateOutcome>()\n const writes: ContactWrite[] = []\n snapshots.forEach((snapshot, index) => {\n const contactId = contactIds[index]\n if (!snapshot.exists) {\n outcomes.set(contactId, refused(contactId, 'That contact no longer exists.'))\n return\n }\n const data = (snapshot.data() ?? {}) as Record<string, unknown>\n const visibleTo = data['visibleTo'] as string[] | undefined\n if (!canReach(writer, visibleTo)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to you.'))\n return\n }\n if (siteTokens && !visibleToTokens(visibleTo, siteTokens)) {\n outcomes.set(contactId, refused(contactId, 'That contact is not visible to this site.'))\n return\n }\n const group = siteGroup ?? contactPrimaryGroup(data, writer.org)\n if (!group.groupId) {\n outcomes.set(contactId, refused(contactId, NO_HOLDER_REFUSAL))\n return\n }\n let rowFields = fields\n if (leadSources && fields.leadSource) {\n const judged = judgeCrmLeadSource(\n leadSources,\n fields.leadSource,\n readContactFacet(data, group.groupId).leadSource,\n )\n if (judged.ok === false) {\n outcomes.set(contactId, refused(contactId, judged.error))\n return\n }\n rowFields = { ...fields, leadSource: judged.value ?? '' }\n }\n const patch = contactPatch(data, group.groupId, rowFields, custom, linkedCompanyName)\n if ('refused' in patch) {\n outcomes.set(contactId, refused(contactId, patch.refused))\n return\n }\n outcomes.set(contactId, { contactId, ok: true })\n if (patch.update) {\n writes.push({ contactId, ref: snapshot.ref, update: patch.update, counts: patch.counts })\n }\n })\n\n const failed = await commitContactWrites(firestore, writes)\n for (const contactId of failed) {\n outcomes.set(contactId, refused(contactId, 'The contact could not be saved.'))\n }\n await settleCompanyCounts(\n orgRef.collection(CRM_COLLECTIONS.companies),\n writes.filter((write) => !failed.has(write.contactId)),\n )\n\n const answer: ContactUpdateResponse = {\n ok: true,\n results: contactIds.map(\n (contactId) => outcomes.get(contactId) ?? refused(contactId, 'The contact could not be saved.'),\n ),\n }\n res.status(200).json(answer)\n } catch (error) {\n console.error('[crm] contact-update failed', error)\n res.status(500).json({ error: 'The contact could not be saved.' })\n }\n}\n"],"names":["consentGroupForHost","CONTACT_FIELDS_MAX_PER_ORG","containerMembershipValue","contactContainerFieldPath","contactFacetPath","CRM_COLLECTIONS","CRM_LEAD_SOURCE_PICKLIST","CRM_MEDIA_IDS_FIELD","crmReadTokens","effectiveCrmLeadSourcePicklist","isOrgWideMember","judgeCrmLeadSource","normalizeAddress","normalizeCrmMediaIds","normalizeCrmPicklistLabel","normalizePhone","planContactCompanyLink","readContactCompanyLink","readContactFacet","readCrmCustomInput","visibleToTokens","companyContactsCountFields","contactCompanyLinkFields","firebaseAdmin","resolveOrgMembership","restampCrmListFieldsOf","FieldValue","CONTACT_UPDATE_FIELDS","CRM_CONTACT_UPDATE_MAX","CONTACT_NOTES_MAX","CONTACT_PHONE_REFUSAL","CONTACT_TAGS_MAX","normalizeTags","readBulkTag","typed","readCrmRouteScope","crmSuiteRefusal","authorizeCrmWriter","canReach","contactPrimaryGroup","SUITE_ACTS","name","phone","jobTitle","leadSource","address","notes","tags","addTag","removeTag","campaignIds","ownerUid","companyId","companyName","custom","mediaIds","NO_HOLDER_REFUSAL","contactUpdateSuiteAct","fields","field","find","key","undefined","readContactUpdateFields","input","raw","Array","isArray","ok","error","unknownKey","Object","keys","includes","text","String","slice","filter","length","tag","ids","map","id","readContactIds","value","some","Set","reachesSite","writer","hostId","staff","member","Boolean","hostAccess","refused","contactId","contactPatch","contact","groupId","linkedCompanyName","path","delete","update","counts","held","toLowerCase","arrayUnion","arrayRemove","plan","assign","label","entries","serverTimestamp","commitContactWrites","firestore","writes","failed","batch","write","ref","committed","commit","console","add","has","settleCompanyCounts","companies","deltas","Map","count","set","get","delta","Promise","all","doc","catch","crmContactUpdateHandler","req","res","method","setHeader","status","json","body","scope","contactIds","read","suiteAct","level","suite","org","app","orgRef","collection","orgId","siteGroup","siteTokens","definitions","contactFields","limit","judged","docs","data","values","errors","leadSources","picklists","owner","company","tokens","exists","visible","contacts","snapshots","getAll","outcomes","forEach","snapshot","index","visibleTo","group","rowFields","patch","push","answer","results"],"mappings":"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"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginMembershipDetacher } from '@aglyn/aglyn/plugin-manager/plugin-membership-detach';
18
+ export interface CrmContainerDetachDeps {
19
+ firestore(): FirebaseFirestore.Firestore;
20
+ /** The site's consent group: whose facet on a shared contact is the site's. */
21
+ consentGroupId(hostId: string): Promise<string>;
22
+ /** Restamps the list fields of leads just written, as each now stands. */
23
+ restampLeads(refs: readonly FirebaseFirestore.DocumentReference[]): Promise<unknown>;
24
+ }
25
+ /** The detacher, on the dependencies it is handed. */
26
+ export declare function createCrmContainerDetacher(deps: CrmContainerDetachDeps): PluginMembershipDetacher;
27
+ /**
28
+ * The detacher on the platform's Firestore, as the console registers it —
29
+ * this module is imported with the first removal, not at boot.
30
+ */
31
+ export declare const crmContainerDetacher: PluginMembershipDetacher;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ import { containerMembershipField, contactContainerFieldPath } from "@aglyn/aglyn/app-utils/container-membership";
17
+ import { listPluginContainerKinds } from "@aglyn/aglyn/plugin-manager/plugin-containers";
18
+ import { restampCrmListFieldsOf } from "@aglyn/tenant-data-admin/server/crm-records";
19
+ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
20
+ import { consentGroupForSite } from "@aglyn/tenant-data-admin/server/organizations";
21
+ import { FieldValue } from "firebase-admin/firestore";
22
+ /**
23
+ * THE CRM'S SHARE OF TAKING A CONTAINER AWAY (AGL-3080).
24
+ *
25
+ * A lead is filed under a container by the container's id in its own
26
+ * `<kind>Ids`, at the top of its document: on the organization
27
+ * (`orgs/{orgId}/leads`), and on the site for the rows the lead migration has
28
+ * not reached (`hosts/{hostId}/leads`). A contact is shared by every site of
29
+ * the organization, so what one merchant filed a person under lives in that
30
+ * merchant's facet, and the field PATH names the site's consent group.
31
+ *
32
+ * The plugin that keeps a container cannot name either, so when it removes
33
+ * one it asks every plugin's membership detacher, once per site, with the
34
+ * container's id and its membership field. This clears the CRM's records
35
+ * with the walk the owner makes over its own: `array-contains` on the
36
+ * automatic single-field index, `arrayRemove` of the one id, in pages under
37
+ * the batch limit, bounded per request and reporting `remaining` so the
38
+ * removal holds the container for a second run. A lead's list fields are
39
+ * restamped as it now stands, because the Leads list's container filter
40
+ * reads them. The records themselves stay.
41
+ *
42
+ * Asked once per site, the organization's leads are walked on every call:
43
+ * after the first finds them, each later walk is one query that answers
44
+ * nothing. Two sites in one consent group share a facet the same way.
45
+ *
46
+ * Any declared container kind: the membership field names its kind
47
+ * (`<kind>Ids`), and a field no declared kind owns is not one a CRM record
48
+ * carries, so it is answered with nothing detached.
49
+ */ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
50
+ /** How many pages one request walks per collection. */ const DETACH_PASSES = 25;
51
+ async function detachFrom(collection, fieldPath, id, afterWrite) {
52
+ const firestore = collection.firestore;
53
+ let detached = 0;
54
+ for(let pass = 0; pass < DETACH_PASSES; pass += 1){
55
+ const page = await collection.where(fieldPath, 'array-contains', id).limit(DETACH_BATCH).get();
56
+ if (page.empty) return {
57
+ detached,
58
+ remaining: false
59
+ };
60
+ const batch = firestore.batch();
61
+ for (const member of page.docs){
62
+ batch.update(member.ref, {
63
+ [fieldPath]: FieldValue.arrayRemove(id)
64
+ });
65
+ }
66
+ await batch.commit();
67
+ await (afterWrite == null ? void 0 : afterWrite(page.docs.map((member)=>member.ref)));
68
+ detached += page.size;
69
+ if (page.size < DETACH_BATCH) return {
70
+ detached,
71
+ remaining: false
72
+ };
73
+ }
74
+ return {
75
+ detached,
76
+ remaining: true
77
+ };
78
+ }
79
+ /** The detacher, on the dependencies it is handed. */ export function createCrmContainerDetacher(deps) {
80
+ return async ({ hostId, orgId, field, id })=>{
81
+ var _listPluginContainerKinds_find;
82
+ const kind = (_listPluginContainerKinds_find = listPluginContainerKinds().find((declared)=>containerMembershipField(declared.kind) === field)) == null ? void 0 : _listPluginContainerKinds_find.kind;
83
+ if (!kind || !id) return {
84
+ detached: 0,
85
+ remaining: false
86
+ };
87
+ const firestore = deps.firestore();
88
+ const passes = [];
89
+ if (hostId) {
90
+ passes.push(await detachFrom(firestore.collection('hosts').doc(hostId).collection('leads'), field, id, deps.restampLeads));
91
+ }
92
+ if (orgId) {
93
+ const org = firestore.collection('orgs').doc(orgId);
94
+ passes.push(await detachFrom(org.collection('leads'), field, id, deps.restampLeads));
95
+ if (hostId) {
96
+ const groupId = await deps.consentGroupId(hostId);
97
+ passes.push(await detachFrom(org.collection('contacts'), contactContainerFieldPath(groupId, kind), id));
98
+ }
99
+ }
100
+ return {
101
+ detached: passes.reduce((sum, pass)=>sum + pass.detached, 0),
102
+ remaining: passes.some((pass)=>pass.remaining)
103
+ };
104
+ };
105
+ }
106
+ /**
107
+ * The detacher on the platform's Firestore, as the console registers it —
108
+ * this module is imported with the first removal, not at boot.
109
+ */ export const crmContainerDetacher = createCrmContainerDetacher({
110
+ firestore: ()=>firebaseAdmin.app().firestore(),
111
+ consentGroupId: async (hostId)=>(await consentGroupForSite(hostId)).groupId,
112
+ restampLeads: (refs)=>restampCrmListFieldsOf(refs, 'leads')
113
+ });
114
+
115
+ //# sourceMappingURL=container-detach.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/container-detach.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 containerMembershipField,\n contactContainerFieldPath,\n} from '@aglyn/aglyn/app-utils/container-membership'\nimport { listPluginContainerKinds } from '@aglyn/aglyn/plugin-manager/plugin-containers'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { restampCrmListFieldsOf } from '@aglyn/tenant-data-admin/server/crm-records'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { consentGroupForSite } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * THE CRM'S SHARE OF TAKING A CONTAINER AWAY (AGL-3080).\n *\n * A lead is filed under a container by the container's id in its own\n * `<kind>Ids`, at the top of its document: on the organization\n * (`orgs/{orgId}/leads`), and on the site for the rows the lead migration has\n * not reached (`hosts/{hostId}/leads`). A contact is shared by every site of\n * the organization, so what one merchant filed a person under lives in that\n * merchant's facet, and the field PATH names the site's consent group.\n *\n * The plugin that keeps a container cannot name either, so when it removes\n * one it asks every plugin's membership detacher, once per site, with the\n * container's id and its membership field. This clears the CRM's records\n * with the walk the owner makes over its own: `array-contains` on the\n * automatic single-field index, `arrayRemove` of the one id, in pages under\n * the batch limit, bounded per request and reporting `remaining` so the\n * removal holds the container for a second run. A lead's list fields are\n * restamped as it now stands, because the Leads list's container filter\n * reads them. The records themselves stay.\n *\n * Asked once per site, the organization's leads are walked on every call:\n * after the first finds them, each later walk is one query that answers\n * nothing. Two sites in one consent group share a facet the same way.\n *\n * Any declared container kind: the membership field names its kind\n * (`<kind>Ids`), and a field no declared kind owns is not one a CRM record\n * carries, so it is answered with nothing detached.\n */\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nexport interface CrmContainerDetachDeps {\n firestore(): FirebaseFirestore.Firestore\n /** The site's consent group: whose facet on a shared contact is the site's. */\n consentGroupId(hostId: string): Promise<string>\n /** Restamps the list fields of leads just written, as each now stands. */\n restampLeads(refs: readonly FirebaseFirestore.DocumentReference[]): Promise<unknown>\n}\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n fieldPath: string,\n id: string,\n afterWrite?: (refs: FirebaseFirestore.DocumentReference[]) => Promise<unknown>,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(fieldPath, 'array-contains', id).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [fieldPath]: FieldValue.arrayRemove(id) })\n }\n await batch.commit()\n await afterWrite?.(page.docs.map((member) => member.ref))\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the dependencies it is handed. */\nexport function createCrmContainerDetacher(deps: CrmContainerDetachDeps): PluginMembershipDetacher {\n return async ({ hostId, orgId, field, id }) => {\n const kind = listPluginContainerKinds().find(\n (declared) => containerMembershipField(declared.kind) === field,\n )?.kind\n if (!kind || !id) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const passes: PluginMembershipDetachReport[] = []\n if (hostId) {\n passes.push(\n await detachFrom(\n firestore.collection('hosts').doc(hostId).collection('leads'),\n field,\n id,\n deps.restampLeads,\n ),\n )\n }\n if (orgId) {\n const org = firestore.collection('orgs').doc(orgId)\n passes.push(await detachFrom(org.collection('leads'), field, id, deps.restampLeads))\n if (hostId) {\n const groupId = await deps.consentGroupId(hostId)\n passes.push(\n await detachFrom(org.collection('contacts'), contactContainerFieldPath(groupId, kind), id),\n )\n }\n }\n return {\n detached: passes.reduce((sum, pass) => sum + pass.detached, 0),\n remaining: passes.some((pass) => pass.remaining),\n }\n }\n}\n\n/**\n * The detacher on the platform's Firestore, as the console registers it —\n * this module is imported with the first removal, not at boot.\n */\nexport const crmContainerDetacher: PluginMembershipDetacher = createCrmContainerDetacher({\n firestore: () => firebaseAdmin.app().firestore(),\n consentGroupId: async (hostId) => (await consentGroupForSite(hostId)).groupId,\n restampLeads: (refs) => restampCrmListFieldsOf(refs, 'leads'),\n})\n"],"names":["containerMembershipField","contactContainerFieldPath","listPluginContainerKinds","restampCrmListFieldsOf","firebaseAdmin","consentGroupForSite","FieldValue","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","fieldPath","id","afterWrite","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","map","size","createCrmContainerDetacher","deps","hostId","orgId","field","kind","find","declared","passes","push","doc","restampLeads","org","groupId","consentGroupId","reduce","sum","some","crmContainerDetacher","app","refs"],"mappings":"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"}
@@ -0,0 +1,162 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import { type CrmActivityLink, type CrmEmailDirection, type RosterMember } from '@aglyn/aglyn/server';
18
+ import type { ReceivedEmail } from '@aglyn/shared-util-email';
19
+ import { type CrmInboundCandidate } from '../model/crm-inbound';
20
+ /**
21
+ * EMAIL CAPTURE (AGL-2657): the half with Firestore.
22
+ *
23
+ * The org's capture token — minted once, replaced on a rotation, found
24
+ * again from a message's recipient — and the filing of one received
25
+ * message: which record it was with, whether it has been filed before, and
26
+ * the row it becomes. The pure rules are `@aglyn/aglyn`'s
27
+ * `crm-inbound.ts`; this module is the reads and the one write.
28
+ *
29
+ * Firestore is a parameter throughout, as in `crm-records.ts` and
30
+ * `crm-email-activity.ts`: the routes hold one, and a module that reached
31
+ * for the Admin app itself would drag it into every spec that only wants
32
+ * the matching. The roster is read through that same store by
33
+ * `loadMemberAddressRoster`, and the caller writes the org's feed line, so a
34
+ * spec of this module needs a store and nothing else.
35
+ */
36
+ /**
37
+ * The workspace's email capture address, as the org document carries it
38
+ * under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.
39
+ *
40
+ * One token per organization, minted lazily by `crm/inbound-address` the
41
+ * first time a member asks for the address, and replaced by the same route
42
+ * on a rotation. Every site of the organization shares it: a captured
43
+ * message is filed on the record that matches its correspondent, and the
44
+ * record says which site it belongs to, so the address need not.
45
+ *
46
+ * The CRM's field, not the platform's: the organization type does not name
47
+ * it, and this module is its only reader and writer. Server-owned and
48
+ * member-readable: the token IS the address, so a client that could write
49
+ * it could point another workspace's mail here.
50
+ */
51
+ export interface OrgCrmInbound {
52
+ /** The url-safe secret in the local part; `crm+<token>@…`. */
53
+ token: string;
54
+ /** When the first token was minted, epoch ms. */
55
+ createdAtMs: number;
56
+ /** When the token was last replaced, epoch ms; absent until it has been. */
57
+ rotatedAtMs?: number;
58
+ }
59
+ /** The most candidate addresses one message is matched by. */
60
+ export declare const CRM_INBOUND_CANDIDATE_MAX = 6;
61
+ /** The most sites whose leads are consulted for one message. */
62
+ export declare const CRM_INBOUND_LEAD_HOST_MAX = 50;
63
+ export interface CrmInboundTokenResult {
64
+ token: string;
65
+ createdAtMs: number;
66
+ rotatedAtMs?: number;
67
+ /** True when this call replaced a token that existed. */
68
+ rotated: boolean;
69
+ }
70
+ /**
71
+ * The org's capture token, minted the first time anybody asks and kept
72
+ * thereafter; with `rotate`, replaced. A transaction, so two members
73
+ * opening the settings page at once mint one token and not two, and a
74
+ * rotation lands on the token the reader saw.
75
+ */
76
+ export declare function ensureCrmInboundToken(firestore: FirebaseFirestore.Firestore, orgId: string, options?: {
77
+ rotate?: boolean;
78
+ nowMs?: number;
79
+ }): Promise<CrmInboundTokenResult>;
80
+ /**
81
+ * The org a capture token names, or `null`. One equality on
82
+ * `crmInbound.token`, which Firestore indexes on its own; a token that is
83
+ * not one by shape is refused before the query, so a malformed local part
84
+ * costs no read.
85
+ */
86
+ export declare function findOrgByCrmInboundToken(firestore: FirebaseFirestore.Firestore, token: unknown): Promise<{
87
+ orgId: string;
88
+ org: Record<string, unknown>;
89
+ } | null>;
90
+ /**
91
+ * Every site of the organization, by id, up to the ceiling — the set a
92
+ * message's lead lookup sweeps. One equality on `hosts.orgId`.
93
+ */
94
+ export declare function crmInboundHostIds(firestore: FirebaseFirestore.Firestore, orgId: string): Promise<string[]>;
95
+ /** The record one of a message's addresses resolved to. */
96
+ export interface CrmInboundMatch {
97
+ email: string;
98
+ direction: CrmEmailDirection;
99
+ kind: 'contact' | 'lead';
100
+ /** Where the row is filed. */
101
+ link: CrmActivityLink;
102
+ /** The site the row is stamped with — the record's own. */
103
+ hostId: string;
104
+ /** Who may list the row — the record's own tokens. */
105
+ visibleTo: string[];
106
+ }
107
+ /**
108
+ * The first candidate that is a contact of the org — through the address
109
+ * index, so a merged record's alternate address answers — or a lead on any
110
+ * of its sites. Candidates are tried in the order the rule gave them and
111
+ * the first hit wins; a contact is looked for before a lead at each
112
+ * address, because a converted lead's address is its contact's.
113
+ */
114
+ export declare function matchCrmInboundCorrespondent(firestore: FirebaseFirestore.Firestore, input: {
115
+ orgId: string;
116
+ org: Record<string, unknown>;
117
+ candidates: readonly CrmInboundCandidate[];
118
+ hostIds: readonly string[];
119
+ }): Promise<CrmInboundMatch | null>;
120
+ export type CrmInboundFileOutcome =
121
+ /** A row was written. */
122
+ {
123
+ outcome: 'filed';
124
+ activityId: string;
125
+ match: CrmInboundMatch;
126
+ }
127
+ /** The message had been filed before; nothing written. */
128
+ | {
129
+ outcome: 'duplicate';
130
+ activityId: string;
131
+ }
132
+ /** No address on the message is a record of the org; nothing written. */
133
+ | {
134
+ outcome: 'unmatched';
135
+ senderDomain: string | null;
136
+ }
137
+ /** The record's activity log is at its ceiling; nothing written. */
138
+ | {
139
+ outcome: 'ceiling';
140
+ match: CrmInboundMatch;
141
+ };
142
+ /**
143
+ * Files one received message on the record it was with (AGL-2657).
144
+ *
145
+ * The order is the cost: the candidate rule is arithmetic; the match is
146
+ * the reads that decide everything after it; a message filed before is
147
+ * answered off one read of the row it would write, before the ceiling's
148
+ * aggregate; the ceiling comes last and refuses rather than dropping a row
149
+ * on a full log. The write is a `create`, so two deliveries racing past
150
+ * the read still yield one row. The BODY never leaves this function except
151
+ * as the bounded excerpt in the row.
152
+ */
153
+ export declare function fileCrmInboundEmail(firestore: FirebaseFirestore.Firestore, input: {
154
+ orgId: string;
155
+ org: Record<string, unknown>;
156
+ message: ReceivedEmail;
157
+ /** The capture domain, so the capture address is never the correspondent. */
158
+ domain: string;
159
+ /** The roster, from `loadMemberAddressRoster`: sign-in addresses and confirmed aliases. */
160
+ members: readonly RosterMember[];
161
+ hostIds: readonly string[];
162
+ }): Promise<CrmInboundFileOutcome>;