@aglyn/plugins-crm 1.0.0 → 1.2.0

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 (166) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/activity-link-fields.d.ts +34 -0
  3. package/src/lib/components/activity-link-fields.js +141 -0
  4. package/src/lib/components/activity-link-fields.js.map +1 -0
  5. package/src/lib/components/activity-list.d.ts +8 -0
  6. package/src/lib/components/activity-list.js +4 -2
  7. package/src/lib/components/activity-list.js.map +1 -1
  8. package/src/lib/components/activity-queries.d.ts +7 -2
  9. package/src/lib/components/activity-queries.js +7 -3
  10. package/src/lib/components/activity-queries.js.map +1 -1
  11. package/src/lib/components/company-contact-roles-card.d.ts +42 -0
  12. package/src/lib/components/company-contact-roles-card.js +137 -0
  13. package/src/lib/components/company-contact-roles-card.js.map +1 -0
  14. package/src/lib/components/company-detail-page.js +12 -1
  15. package/src/lib/components/company-detail-page.js.map +1 -1
  16. package/src/lib/components/company-edit-drawer.js +9 -6
  17. package/src/lib/components/company-edit-drawer.js.map +1 -1
  18. package/src/lib/components/company-picker.js +4 -2
  19. package/src/lib/components/company-picker.js.map +1 -1
  20. package/src/lib/components/contact-associations-card.js +15 -1
  21. package/src/lib/components/contact-associations-card.js.map +1 -1
  22. package/src/lib/components/contact-detail-page.js +13 -14
  23. package/src/lib/components/contact-detail-page.js.map +1 -1
  24. package/src/lib/components/contact-known-by-card.d.ts +2 -1
  25. package/src/lib/components/contact-known-by-card.js +16 -3
  26. package/src/lib/components/contact-known-by-card.js.map +1 -1
  27. package/src/lib/components/contact-list-columns.js +5 -4
  28. package/src/lib/components/contact-list-columns.js.map +1 -1
  29. package/src/lib/components/contact-properties-card.js +41 -20
  30. package/src/lib/components/contact-properties-card.js.map +1 -1
  31. package/src/lib/components/contact-roles-editor.d.ts +54 -0
  32. package/src/lib/components/contact-roles-editor.js +270 -0
  33. package/src/lib/components/contact-roles-editor.js.map +1 -0
  34. package/src/lib/components/contacts-section.js +6 -4
  35. package/src/lib/components/contacts-section.js.map +1 -1
  36. package/src/lib/components/crm-console-sections.js +1 -1
  37. package/src/lib/components/crm-console-sections.js.map +1 -1
  38. package/src/lib/components/crm-record-picker.d.ts +19 -0
  39. package/src/lib/components/crm-record-picker.js +40 -11
  40. package/src/lib/components/crm-record-picker.js.map +1 -1
  41. package/src/lib/components/crm-related-to-picker.d.ts +33 -0
  42. package/src/lib/components/crm-related-to-picker.js +135 -0
  43. package/src/lib/components/crm-related-to-picker.js.map +1 -0
  44. package/src/lib/components/crm-site-picker.d.ts +13 -1
  45. package/src/lib/components/crm-site-picker.js +15 -6
  46. package/src/lib/components/crm-site-picker.js.map +1 -1
  47. package/src/lib/components/crm-transfer-buttons.d.ts +5 -0
  48. package/src/lib/components/crm-transfer-buttons.js +9 -6
  49. package/src/lib/components/crm-transfer-buttons.js.map +1 -1
  50. package/src/lib/components/deal-contact-roles-card.js +21 -239
  51. package/src/lib/components/deal-contact-roles-card.js.map +1 -1
  52. package/src/lib/components/deal-detail-page.js +2 -1
  53. package/src/lib/components/deal-detail-page.js.map +1 -1
  54. package/src/lib/components/deal-edit-drawer.js +11 -7
  55. package/src/lib/components/deal-edit-drawer.js.map +1 -1
  56. package/src/lib/components/deals-section.js +5 -4
  57. package/src/lib/components/deals-section.js.map +1 -1
  58. package/src/lib/components/log-activity-dialog.d.ts +27 -9
  59. package/src/lib/components/log-activity-dialog.js +96 -32
  60. package/src/lib/components/log-activity-dialog.js.map +1 -1
  61. package/src/lib/components/new-contact-drawer.d.ts +7 -3
  62. package/src/lib/components/new-contact-drawer.js +27 -15
  63. package/src/lib/components/new-contact-drawer.js.map +1 -1
  64. package/src/lib/components/pipeline-stages-dialog.js +5 -1
  65. package/src/lib/components/pipeline-stages-dialog.js.map +1 -1
  66. package/src/lib/components/record-activity-card.d.ts +11 -2
  67. package/src/lib/components/record-activity-card.js +90 -5
  68. package/src/lib/components/record-activity-card.js.map +1 -1
  69. package/src/lib/components/settings-section.d.ts +4 -2
  70. package/src/lib/components/settings-section.js +7 -5
  71. package/src/lib/components/settings-section.js.map +1 -1
  72. package/src/lib/hooks/crm-activity-client-reads.d.ts +8 -0
  73. package/src/lib/hooks/crm-activity-client-reads.js +50 -0
  74. package/src/lib/hooks/crm-activity-client-reads.js.map +1 -0
  75. package/src/lib/hooks/use-campaign-filing-log.d.ts +2 -1
  76. package/src/lib/hooks/use-campaign-filing-log.js +10 -3
  77. package/src/lib/hooks/use-campaign-filing-log.js.map +1 -1
  78. package/src/lib/hooks/use-crm-org-mount.d.ts +8 -6
  79. package/src/lib/hooks/use-crm-org-mount.js +2 -1
  80. package/src/lib/hooks/use-crm-org-mount.js.map +1 -1
  81. package/src/lib/hooks/use-crm-record-names.d.ts +3 -2
  82. package/src/lib/hooks/use-crm-record-names.js +4 -4
  83. package/src/lib/hooks/use-crm-record-names.js.map +1 -1
  84. package/src/lib/hooks/use-crm-scope.d.ts +17 -5
  85. package/src/lib/hooks/use-crm-scope.js +12 -5
  86. package/src/lib/hooks/use-crm-scope.js.map +1 -1
  87. package/src/lib/model/contact-holder.d.ts +12 -0
  88. package/src/lib/model/contact-holder.js +20 -1
  89. package/src/lib/model/contact-holder.js.map +1 -1
  90. package/src/lib/model/contact-record.d.ts +5 -0
  91. package/src/lib/model/contact-record.js +7 -1
  92. package/src/lib/model/contact-record.js.map +1 -1
  93. package/src/lib/model/contact-update.d.ts +6 -0
  94. package/src/lib/model/contact-update.js +1 -0
  95. package/src/lib/model/contact-update.js.map +1 -1
  96. package/src/lib/model/crm-activity-link.d.ts +117 -0
  97. package/src/lib/model/crm-activity-link.js +113 -0
  98. package/src/lib/model/crm-activity-link.js.map +1 -0
  99. package/src/lib/model/crm-api.d.ts +4 -1
  100. package/src/lib/model/crm-api.js +9 -5
  101. package/src/lib/model/crm-api.js.map +1 -1
  102. package/src/lib/model/deal-board-model.js +5 -3
  103. package/src/lib/model/deal-board-model.js.map +1 -1
  104. package/src/lib/model/deal-contact-roles.d.ts +25 -1
  105. package/src/lib/model/deal-contact-roles.js +30 -4
  106. package/src/lib/model/deal-contact-roles.js.map +1 -1
  107. package/src/lib/model/native-vocabulary.d.ts +9 -0
  108. package/src/lib/model/native-vocabulary.js +13 -0
  109. package/src/lib/model/native-vocabulary.js.map +1 -1
  110. package/src/lib/model/settle-stage-names.d.ts +25 -0
  111. package/src/lib/model/settle-stage-names.js +29 -0
  112. package/src/lib/model/settle-stage-names.js.map +1 -0
  113. package/src/lib/native-nav.js +1 -1
  114. package/src/lib/native-nav.js.map +1 -1
  115. package/src/lib/server/add-contact-by-hand.d.ts +84 -0
  116. package/src/lib/server/add-contact-by-hand.js +212 -0
  117. package/src/lib/server/add-contact-by-hand.js.map +1 -0
  118. package/src/lib/server/api-v1/contacts.js +61 -23
  119. package/src/lib/server/api-v1/contacts.js.map +1 -1
  120. package/src/lib/server/api-v1/crm-activities.js +13 -3
  121. package/src/lib/server/api-v1/crm-activities.js.map +1 -1
  122. package/src/lib/server/api-v1/crm-pipelines.d.ts +1 -1
  123. package/src/lib/server/api-v1/crm-pipelines.js +8 -2
  124. package/src/lib/server/api-v1/crm-pipelines.js.map +1 -1
  125. package/src/lib/server/api-v1/crm-shared.d.ts +16 -9
  126. package/src/lib/server/api-v1/crm-shared.js +23 -11
  127. package/src/lib/server/api-v1/crm-shared.js.map +1 -1
  128. package/src/lib/server/api-v1/openapi.js +3 -3
  129. package/src/lib/server/api-v1/openapi.js.map +1 -1
  130. package/src/lib/server/automation-steps.js +11 -9
  131. package/src/lib/server/automation-steps.js.map +1 -1
  132. package/src/lib/server/campaign-filing-activity.js +7 -2
  133. package/src/lib/server/campaign-filing-activity.js.map +1 -1
  134. package/src/lib/server/contact-update.d.ts +4 -0
  135. package/src/lib/server/contact-update.js +71 -10
  136. package/src/lib/server/contact-update.js.map +1 -1
  137. package/src/lib/server/convert-host-lead.js +13 -2
  138. package/src/lib/server/convert-host-lead.js.map +1 -1
  139. package/src/lib/server/convert-open-lead.js +8 -1
  140. package/src/lib/server/convert-open-lead.js.map +1 -1
  141. package/src/lib/server/crm-activity-company.d.ts +25 -0
  142. package/src/lib/server/crm-activity-company.js +34 -0
  143. package/src/lib/server/crm-activity-company.js.map +1 -0
  144. package/src/lib/server/crm-inbound-email.js +21 -15
  145. package/src/lib/server/crm-inbound-email.js.map +1 -1
  146. package/src/lib/server/crm-sharing.js +9 -2
  147. package/src/lib/server/crm-sharing.js.map +1 -1
  148. package/src/lib/server/deal-contact-roles.d.ts +2 -0
  149. package/src/lib/server/deal-contact-roles.js +38 -1
  150. package/src/lib/server/deal-contact-roles.js.map +1 -1
  151. package/src/lib/server/email-send.js +10 -8
  152. package/src/lib/server/email-send.js.map +1 -1
  153. package/src/lib/server/merge-contacts.js +6 -2
  154. package/src/lib/server/merge-contacts.js.map +1 -1
  155. package/src/lib/server/org-caller.d.ts +8 -0
  156. package/src/lib/server/org-caller.js +10 -0
  157. package/src/lib/server/org-caller.js.map +1 -1
  158. package/src/lib/server/record-timeline.d.ts +3 -1
  159. package/src/lib/server/record-timeline.js +11 -3
  160. package/src/lib/server/record-timeline.js.map +1 -1
  161. package/src/lib/server.js +152 -43
  162. package/src/lib/server.js.map +1 -1
  163. package/src/lib/transfer/contacts.js +83 -48
  164. package/src/lib/transfer/contacts.js.map +1 -1
  165. package/src/lib/transfer/fields.js +1 -2
  166. package/src/lib/transfer/fields.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-host-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A lead becomes a contact, and optionally a company and a deal (AGL-2608,\n * AGL-2627).\n *\n * ## Why one function under two doors\n *\n * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`\n * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two\n * doors authenticate differently — an ID token against the site's role, an\n * API key against its scopes — and answer in different envelopes, but what\n * they DO has to be one thing: a conversion that opened a second deal for\n * one person because two callers reached the same lead through two\n * implementations is the bug this file exists to make impossible. The doors\n * own their transport; this owns the writes.\n *\n * It lives here, in the tenancy runtime, rather than beside the plugin\n * route it came from, because the console's REST layer may not import a\n * feature plugin (the app→addons boundary), while both the plugin and the\n * console may import the runtime — which already holds the capture door\n * and the owner assignment this conversion goes through.\n *\n * ## Why a server function and not four client writes\n *\n * The lead is a host document the browser may edit, and a company or a deal\n * is a client-creatable org document. What the browser cannot do is CREATE A\n * CONTACT: the contacts collection is written only by `captureHostContact`\n * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`\n * fires), because that function is the dedupe — one human, one row, found by\n * normalized address across every site in the org — and the audience-band\n * gate. A client that wrote a contact document of its own would mint a\n * second row for a person the org already holds, and would do it past the\n * band.\n *\n * Once the contact has to come from the server, the rest follows it: the\n * conversion is one act with four writes, and a caller that made three of\n * them and lost the connection before the fourth leaves a lead marked\n * qualified with no contact, or a deal pointing at a contact that was never\n * stamped on the lead. Here the order is fixed — contact, then company, then\n * deal, then the lead — and the lead is stamped LAST, so a lead that carries\n * `convertedContactId` names a contact that exists.\n *\n * ## Idempotent on the lead\n *\n * A lead already carrying `convertedContactId` answers with the ids it has\n * and creates nothing more. A double-click on the dialog's button, a retry\n * after a slow response, or a Zapier zap replaying a step must not open a\n * second deal for one person.\n *\n * ## The actor\n *\n * A signed-in member converting from the console owns what nobody else\n * claimed: their uid is the owner of last resort, written directly rather\n * than through the deliberate reassignment, because they may be staff\n * converting on a workspace's behalf and a roster check would refuse the one\n * person who is actually here. An API key is not a person and is on no\n * roster, so for an `api` actor that last step is skipped and a contact\n * nobody chose an owner for — and no rule assigned — stays unowned, which\n * the console renders honestly as unassigned. Either way the caller has\n * already been authorized by its door; nothing here asks again.\n *\n * ## The deal carries the lead's lead source (AGL-3516)\n *\n * As Salesforce's conversion does, the deal opened here takes the lead's\n * `leadSource` — a label of the same picklist, already judged when the lead\n * was written — and its Type as the door judged it, or the org's default\n * Type when the converter named none. Its forecast category is its stage's.\n *\n * ## Every standard field lands where Salesforce's conversion puts it (AGL-3513)\n *\n * The contact's facet takes the salutation, the name's parts, the mobile,\n * the fax and Do not call. The company takes the account fields — industry,\n * rating, revenue and its currency, head count, website, phone, fax, the\n * address as its billing address, and the lead source as its Account\n * Source — whole when the conversion creates it, and only into its BLANK\n * fields when it links one the org already holds: a lead is one person's\n * account of the business, and the account record may know better. The\n * deal's Primary Campaign Source is the lead's most recent campaign — see\n * {@link leadLatestCampaignId}.\n */\n\nimport {\n consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n CRM_LEAD_STATUS_PICKLIST,\n crmLeadStatusLabelFor,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n CRM_COMPANY_PICKLIST_FIELDS,\n type CrmPicklist,\n type CrmPicklistId,\n crmPicklistDefaultLabel,\n judgeCrmCompanyPicklists,\n readCrmCompanyAccountFields,\n crmScopeTokens,\n dealStageForecastCategory,\n DEFAULT_DEAL_STAGES,\n grantedUnderConsentGroup,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n crmNewRecordListFields,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n restampCrmListFieldsAt,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { assignOwnerForCapture, notifyRecordAssigned } from './assign-contact-owner'\nimport { captureHostContact } from './capture-host-contact'\nimport { readCrmPicklist } from './read-picklist'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { readContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\n\n/** Who is converting, as far as the writes need to know. */\nexport interface LeadConvertActor {\n /**\n * What `createdByUid` records on every record the conversion opens, and\n * who the audit line names. A member's uid, or the literal `'api'` for a\n * key — the same attribution the REST creates stamp.\n */\n uid: string\n email?: string | null\n /**\n * `member`: a signed-in person, who becomes the owner when nobody else\n * does. `api`: a key, which cannot own a record — see the header.\n */\n kind: 'member' | 'api'\n /**\n * For an `api` actor, the key's name — what the audit line attributes the\n * conversion to, since a key has no address to be named by.\n */\n apiKeyName?: string | null\n}\n\n/** What a conversion is asked to do. Validated by the door before it gets here. */\nexport interface ConvertHostLeadInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orgId: string\n /** The org document, as the door resolved it. */\n org: Record<string, unknown>\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n actor: LeadConvertActor\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those a member actor — somebody\n * converted this person, and a record with no owner is one nobody follows\n * up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. Already normalized. */\n createCompany?: { name: string; domain: string | null } | null\n /** Open a deal in the org's default pipeline. Already normalized. */\n deal?: {\n title: string\n amountCents: number | null\n /** Lowercase ISO 4217. */\n currency: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n /**\n * The deal's Type, as the door judged it against the org's\n * `opportunityType` list (AGL-3516); absent takes the list's default.\n */\n type?: string\n } | null\n}\n\n/**\n * Why a conversion did not happen. Each is a state of the data, not a fault\n * of the request — the door has already refused a malformed body — and each\n * leaves the lead unconverted so a retry after the remedy finds it where it\n * was.\n */\nexport type ConvertHostLeadRefusal =\n /** No document at `hosts/{hostId}/leads/{leadId}`. */\n | 'unknown-lead'\n /** The lead's address cannot become a contact. */\n | 'no-email'\n /**\n * The capture door produced no contact — it swallows its own failures and\n * drops a creation past a Free org's audience band. Nothing was written.\n */\n | 'contact-not-created'\n /**\n * The site holds an erasure for the lead's address (AGL-2623): the person\n * asked to be forgotten, and the capture door refuses to rebuild a record\n * for them — by a conversion as by a form. Nothing was written, and the\n * lead stays as it was; the erasure removes it when it runs.\n */\n | 'erased'\n /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */\n | 'band-full'\n /** `companyId` names no company in this organization. The contact stands. */\n | 'unknown-company'\n /** The default pipeline has no stages to open a deal in. The contact stands. */\n | 'no-stages'\n\nexport type ConvertHostLeadResult =\n | {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n }\n | { ok: false; reason: ConvertHostLeadRefusal }\n\n/** The stamp the conversion leaves on the lead — see `CrmLeadFields`. */\ntype LeadConversionStamp = Required<\n Pick<CrmLeadFields, 'status' | 'statusLabel' | 'convertedContactId' | 'convertedAtMs'>\n> &\n Pick<CrmLeadFields, 'dealId' | 'companyId' | 'ownerUid'>\n\n/**\n * The stage a new deal opens in.\n *\n * The caller's choice when the pipeline has it, otherwise the first OPEN\n * stage by order — a deal created by converting a lead is by definition not\n * yet won or lost, so defaulting to a closed stage would record an outcome\n * nobody reached. A pipeline with no open stage at all falls back to its\n * first stage rather than refusing: the merchant edited their pipeline into\n * that shape, and a conversion that failed because of it would read as a bug\n * in the lead.\n */\nexport function stageForNewDeal(\n pipeline: Pick<CrmPipeline, 'stages'>,\n requestedStageId: string | undefined,\n): CrmDealStage | null {\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n if (!stages.length) return null\n const requested = requestedStageId\n ? stages.find((stage) => stage.id === requestedStageId)\n : undefined\n return requested ?? stages.find((stage) => stage.kind === 'open') ?? stages[0]\n}\n\n/**\n * The lead's MOST RECENT campaign, which becomes the deal's Primary\n * Campaign Source as Salesforce's conversion makes it. A lead keeps its\n * campaigns as one `campaignIds` array that every writer extends with\n * `arrayUnion`, which appends — so the last id is the campaign the lead was\n * filed under last. `undefined` for a lead filed under none.\n */\nexport function leadLatestCampaignId(lead: Readonly<Record<string, unknown>>): string | undefined {\n const ids = readContainerIds(lead as Record<string, unknown>, 'campaign')\n return ids.length ? ids[ids.length - 1] : undefined\n}\n\n/** Whether a stored field holds nothing a fill would overwrite. */\nconst blankField = (value: unknown): boolean =>\n value === undefined ||\n value === null ||\n (typeof value === 'string' && !value.trim()) ||\n (typeof value === 'object' && !Array.isArray(value) && !Object.keys(value as object).length)\n\n/**\n * The account fields a lead hands its company (AGL-3513), normalized and\n * judged as every company door judges them: the lead's industry and rating\n * against the org's lists, its lead source as the Account Source, and its\n * revenue, currency, head count, fax, website, phone and address (as the\n * billing address). With `current` — a company the org already holds —\n * only the fields it leaves blank are answered, and the picklists it does\n * not name keep no default; without it, the company is being created and\n * each picklist the lead leaves empty starts from its list's default. A\n * value a list no longer holds is left behind rather than refusing the\n * conversion.\n */\nexport function leadCompanyFields(\n lead: Readonly<Record<string, unknown>>,\n lists: Readonly<Partial<Record<CrmPicklistId, CrmPicklist>>>,\n current: Readonly<Record<string, unknown>> | null,\n): Record<string, unknown> {\n const fill = (field: string) => !current || blankField(current[field])\n const offered = (value: unknown) => (blankField(value) ? undefined : value)\n const fields: Record<string, unknown> = {}\n const requested: Record<string, unknown> = {}\n const picklistSource: Record<string, unknown> = {\n industry: lead['industry'],\n rating: lead['rating'],\n accountSource: lead['leadSource'],\n }\n for (const { field } of CRM_COMPANY_PICKLIST_FIELDS) {\n const value = offered(picklistSource[field])\n if (value !== undefined && fill(field)) requested[field] = value\n }\n const picklists = judgeCrmCompanyPicklists(lists, requested, {\n current: current ?? {},\n created: !current,\n })\n for (const [field, value] of Object.entries(picklists.values)) {\n if (value && !picklists.errors[field]) fields[field] = value\n }\n const account: Record<string, unknown> = {}\n for (const field of ['annualRevenueCents', 'numberOfEmployees', 'fax'] as const) {\n const value = offered(lead[field])\n if (value !== undefined && fill(field)) account[field] = value\n }\n // The revenue's currency travels with the revenue it describes.\n if (account['annualRevenueCents'] !== undefined && offered(lead['currency']) !== undefined) {\n account['currency'] = lead['currency']\n }\n const read = readCrmCompanyAccountFields(account)\n for (const [field, value] of Object.entries(read.values)) {\n if (value !== null && value !== undefined && !read.errors[field]) fields[field] = value\n }\n // Already in the company's shapes: the lead normalized them as a company does.\n for (const [field, from] of [\n ['website', 'website'],\n ['phone', 'phone'],\n ['address', 'address'],\n ] as const) {\n const value = offered(lead[from])\n if (value !== undefined && fill(field)) fields[field] = value\n }\n return fields\n}\n\n/** The org's lists behind every company picklist field, for {@link leadCompanyFields}. */\nasync function readCompanyPicklists(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<Partial<Record<CrmPicklistId, CrmPicklist>>> {\n const ids = [...new Set(CRM_COMPANY_PICKLIST_FIELDS.map((entry) => entry.picklistId))]\n const lists = await Promise.all(ids.map((id) => readCrmPicklist(firestore, orgId, id)))\n return Object.fromEntries(ids.map((id, at) => [id, lists[at]]))\n}\n\n/** A stage's kind as the deal's denormalized status. */\nfunction dealStatusForStage(stage: CrmDealStage): CrmDealStatus {\n return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n}\n\n/**\n * Convert one lead. Throws only on infrastructure failure; every refusal\n * the data can produce is a `ConvertHostLeadResult`.\n */\nexport async function convertHostLead(\n input: ConvertHostLeadInput,\n): Promise<ConvertHostLeadResult> {\n const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input\n const requestedCompanyId = String(input.companyId ?? '').trim()\n // The lead's one home, on the org (AGL-3275). `orgId` is already resolved\n // by the caller, so this needs no second lookup to find it.\n const leadRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .doc(leadId)\n const leadSnapshot = await leadRef.get()\n if (!leadSnapshot.exists) return { ok: false, reason: 'unknown-lead' }\n const lead = (leadSnapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId) {\n return {\n ok: true,\n contactId: lead.convertedContactId,\n ...(lead.companyId ? { companyId: lead.companyId } : {}),\n ...(lead.dealId ? { dealId: lead.dealId } : {}),\n alreadyConverted: true,\n }\n }\n const email = normalizeContactEmail(lead['email'])\n if (!email) return { ok: false, reason: 'no-email' }\n\n const group = await consentGroupForSite(hostId, org)\n const visibleTo = crmScopeTokens(org, group)\n /*\n * What the CALLER may read, for the company-by-domain reuse below: their\n * group's own tokens plus `org`, exactly the set the console's listeners\n * filter on. A company outside it is one they could not open, so linking\n * a contact to it would point at a record the page then 404s on.\n */\n const readableTokens = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...consentGroupScope(group),\n ])\n /*\n * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else\n * whoever was already working the lead. Handed to the capture as the\n * facet's owner, which is what tells the capture's own assignment pass\n * to stand down — a person's choice outranks a rule. When nobody chose,\n * the facet names no owner and the pass runs the org's rules and the\n * site's default for a contact it creates (AGL-2618).\n */\n const pickedOwner = String(input.ownerUid ?? '').trim()\n const chosenOwner =\n pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '')\n const now = Date.now()\n const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined\n\n /*==========================================\n * 1. THE CONTACT — through the one door that dedupes and meters.\n *\n * `source: 'manual'` because a person converted this lead by hand, and\n * the source filter should say so; the interaction names the lead so the\n * contact's timeline can be walked back to what was captured. The facet\n * carries the stage and the owner, which is what makes this a sales\n * record rather than another form capture.\n *\n * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce\n * hands a lead's fields to the contact: phone, title, lead source, address, the\n * company as text (until step 2 links a record, whose own name then\n * replaces it), tags, and the marketing basis the lead recorded — the\n * person ticked the box on the lead, and a conversion must not be the\n * moment they lose it. Only what the lead holds is handed over; a value\n * the contact already carries is the contact's, because the capture door\n * writes a profile field only where the facet has none.\n *=========================================*/\n const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : []\n const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId))\n /*\n * WHICH SITES THE CONTACT'S GRANT COVERS (AGL-3320): the lead's, never\n * more. The capture door records a fresh grant, so it is handed the group's\n * disclosure key only when the lead ALREADY holds a grant given under the\n * group as it stands, at every site it names — a lead captured by one site\n * before the group existed, or before a site joined, converts to a contact\n * that site alone may mail, exactly as the lead was.\n */\n const leadDisclosure = grantedUnderConsentGroup(lead, group)\n ? consentGroupDisclosureKey(group)\n : null\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n // Who converted it, for the runs its contactCreated sets off (AGL-3376).\n actor:\n actor.kind === 'member'\n ? { kind: 'member', uid: actor.uid, email: actor.email ?? null }\n : { kind: 'apiKey', apiKeyName: actor.apiKeyName ?? null },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadConsent.basis === 'granted' && leadDisclosure\n ? { disclosedConsentGroup: leadDisclosure }\n : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n // Salesforce's Lead Source travels to the contact (AGL-3298).\n ...(typeof lead.leadSource === 'string' && lead.leadSource\n ? { leadSource: lead.leadSource }\n : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\n // Salesforce's standard lead fields (AGL-3513), as the contact keeps them.\n ...Object.fromEntries(\n (['salutation', 'firstName', 'lastName', 'mobilePhone', 'fax'] as const)\n .filter((key) => typeof lead[key] === 'string' && lead[key])\n .map((key) => [key, lead[key]]),\n ),\n // Only ever `true`: a lead that never said is not a person who asked to be called.\n ...(lead.doNotCall === true ? { doNotCall: true } : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The owner is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {\n await notifyRecordAssigned({\n hostId,\n orgId,\n ownerUid,\n actorUid: actor.kind === 'member' ? actor.uid : null,\n record: { kind: 'contact', id: contactId },\n who: leadName || email,\n })\n }\n\n /*==========================================\n * 2. THE COMPANY — linked, found by domain, or created.\n *=========================================*/\n let companyId: string | undefined\n /** The company the conversion found rather than made — its blanks are filled below. */\n let existingCompany: FirebaseFirestore.DocumentSnapshot | undefined\n if (requestedCompanyId) {\n const companySnapshot = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(requestedCompanyId)\n .get()\n if (!companySnapshot.exists) return { ok: false, reason: 'unknown-company' }\n companyId = requestedCompanyId\n existingCompany = companySnapshot\n } else if (createCompany) {\n /*\n * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its\n * company as text, and a company the org already files under that\n * name is the account, whatever domain it carries. Then the domain,\n * the key two contacts at one business share — a second company\n * document for `acme.com` would split the account the key exists to\n * join. Only a company the caller can see counts as found — see\n * `readableTokens`.\n */\n const visibleAmong = (\n snapshots: readonly FirebaseFirestore.QueryDocumentSnapshot[],\n ): FirebaseFirestore.QueryDocumentSnapshot | undefined =>\n snapshots.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n existingCompany = visibleAmong(byName.docs)\n if (!existingCompany && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n existingCompany = visibleAmong(byDomain.docs)\n }\n companyId = existingCompany?.id\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const company: Record<string, unknown> = {\n // The lead's account fields, whole (AGL-3513).\n ...leadCompanyFields(lead, await readCompanyPicklists(firestore, orgId), null),\n ...nameSearchFields(createCompany.name),\n ...(createCompany.domain ? { domain: createCompany.domain } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...company,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', company),\n })\n companyId = created.id\n }\n }\n if (companyId && existingCompany) {\n /*\n * A company the org already held takes the lead's account fields only\n * where it has none (AGL-3513) — and is restamped, because its\n * industry, rating and account source are what its list filters by.\n */\n const filled = leadCompanyFields(\n lead,\n await readCompanyPicklists(firestore, orgId),\n (existingCompany.data() ?? {}) as Record<string, unknown>,\n )\n if (Object.keys(filled).length) {\n await existingCompany.ref.update({ ...filled, updatedAt: FieldValue.serverTimestamp() })\n await restampCrmListFieldsAt(existingCompany.ref, 'companies')\n }\n }\n if (companyId) {\n /*\n * The one link writer (AGL-2613): the holder's facet field the contact\n * record reads, the queryable top-level mirror the company's contacts\n * card filters on, and the company's contacts count — planned from\n * the row as the capture left it, so a person the org already held\n * under another company is MOVED rather than counted twice.\n */\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: contactData,\n companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),\n groupId: group.groupId,\n companyId,\n })\n }\n\n /*==========================================\n * 3. THE DEAL — in the default pipeline, seeded if the org has none.\n *=========================================*/\n let dealId: string | undefined\n if (deal) {\n const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines)\n let pipelineId: string\n let pipeline: Pick<CrmPipeline, 'stages'>\n const defaults = await pipelinesRef\n .where('isDefault', '==', true)\n .limit(1)\n .get()\n if (!defaults.empty) {\n pipelineId = defaults.docs[0].id\n pipeline = defaults.docs[0].data() as CrmPipeline\n } else {\n /*\n * The deals section's own read shape, so the two agree on which\n * pipeline \"the org's pipeline\" is when none is flagged default: the\n * first by document id of a bounded window. Seeding a SECOND pipeline\n * here while one exists unflagged would leave the merchant with two\n * boards and their deals split between them.\n */\n const any = await pipelinesRef\n .orderBy(FieldPath.documentId())\n .limit(20)\n .get()\n if (!any.empty) {\n pipelineId = any.docs[0].id\n pipeline = any.docs[0].data() as CrmPipeline\n } else {\n // Exactly what the Deals section seeds: one `Sales` pipeline\n // carrying a COPY of the default stages, flagged default.\n const seeded: Omit<CrmPipeline, 'createdAt' | 'updatedAt'> = {\n name: 'Sales',\n stages: [...DEFAULT_DEAL_STAGES],\n isDefault: true,\n visibleTo,\n hostId,\n }\n const created = await pipelinesRef.add({\n ...seeded,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n pipelineId = created.id\n pipeline = seeded\n }\n }\n const stage = stageForNewDeal(pipeline, deal.stageId)\n if (!stage) return { ok: false, reason: 'no-stages' }\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const type =\n deal.type ??\n crmPicklistDefaultLabel(await readCrmPicklist(firestore, orgId, 'opportunityType')) ??\n undefined\n const leadSource = typeof lead.leadSource === 'string' ? lead.leadSource.trim() : ''\n // The Primary Campaign Source: the lead's most recent campaign (AGL-3513).\n const campaignId = leadLatestCampaignId(lead)\n const dealRecord: Record<string, unknown> = {\n title: deal.title,\n titleLower: deal.title.toLowerCase(),\n pipelineId,\n stageId: stage.id,\n status: dealStatusForStage(stage),\n ...(deal.amountCents !== null ? { amountCents: deal.amountCents } : {}),\n currency: deal.currency,\n stageChangedAtMs: now,\n forecastCategory: dealStageForecastCategory(stage),\n ...(type ? { type } : {}),\n ...(leadSource ? { leadSource } : {}),\n ...(campaignId ? { campaignId } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n contactId,\n // The converted person is the deal's Primary contact role, with no\n // role yet, as Salesforce's conversion makes them (AGL-3521).\n contactRoles: [{ contactId, primary: true }],\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n ...dealRecord,\n // What the Deals list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('deals', dealRecord),\n })\n dealId = created.id\n }\n\n /*==========================================\n * 4. THE LEAD — stamped last, once everything it names exists.\n *=========================================*/\n const stamp: LeadConversionStamp = {\n status: 'qualified',\n // The org's label for Qualified, beside the meaning (AGL-3512).\n statusLabel: crmLeadStatusLabelFor(\n await readCrmPicklist(firestore, orgId, CRM_LEAD_STATUS_PICKLIST),\n 'qualified',\n ),\n convertedContactId: contactId,\n convertedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n }\n await leadRef.update({ ...stamp, updatedAt: FieldValue.serverTimestamp() })\n\n /*\n * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the\n * contact, and every plugin with a listener — Sequences, for an\n * enrollment naming the lead — re-points its own records. After the\n * stamp, so a listener reading the lead finds the conversion on it; it\n * never throws.\n */\n await handOffLeadRecords({\n firestore,\n orgId,\n hostId,\n leadId,\n contactId,\n email,\n by: actor.kind === 'member' ? 'member' : 'api',\n })\n\n /*\n * The audit line, from the function that did the work (AGL-2622): the\n * conversion is one act however many records it opened, so it is one\n * entry, on the lead, in the feed of the site that holds the lead. A\n * repeat call answered above with the ids it already had wrote nothing\n * and logs nothing.\n */\n await logHostActivity(\n hostId,\n {\n uid: actor.uid,\n email: actor.email ?? null,\n ...(actor.apiKeyName ? { apiKeyName: actor.apiKeyName } : {}),\n },\n 'Converted lead',\n { type: 'lead', id: leadId, name: String(lead['name'] ?? '') || email },\n )\n\n return {\n ok: true,\n contactId,\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n alreadyConverted: false,\n }\n}\n"],"names":["consentGroupDisclosureKey","consentGroupScope","CRM_COLLECTIONS","CRM_LEAD_STATUS_PICKLIST","crmLeadStatusLabelFor","CRM_COMPANY_PICKLIST_FIELDS","crmPicklistDefaultLabel","judgeCrmCompanyPicklists","readCrmCompanyAccountFields","crmScopeTokens","dealStageForecastCategory","DEFAULT_DEAL_STAGES","grantedUnderConsentGroup","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","crmNewRecordListFields","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","restampCrmListFieldsAt","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","readCrmPicklist","handOffLeadRecords","readContainerIds","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","leadLatestCampaignId","lead","ids","blankField","value","trim","Array","isArray","Object","keys","leadCompanyFields","lists","current","fill","field","offered","fields","picklistSource","industry","rating","accountSource","picklists","created","entries","values","errors","account","read","from","readCompanyPicklists","firestore","orgId","Set","map","entry","picklistId","Promise","all","fromEntries","at","dealStatusForStage","convertHostLead","input","leadSnapshot","actor","contactSnapshot","hostId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","leadTags","tags","leadConsent","leadDisclosure","captured","name","source","interaction","summary","refId","uid","apiKeyName","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","filter","key","doNotCall","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","outcome","update","updatedAt","serverTimestamp","record","who","existingCompany","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","createdByUid","createdAt","add","filled","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","type","campaignId","dealRecord","title","titleLower","status","amountCents","currency","stageChangedAtMs","forecastCategory","contactRoles","primary","deals","stamp","statusLabel","convertedAtMs","by"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-host-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A lead becomes a contact, and optionally a company and a deal (AGL-2608,\n * AGL-2627).\n *\n * ## Why one function under two doors\n *\n * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`\n * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two\n * doors authenticate differently — an ID token against the site's role, an\n * API key against its scopes — and answer in different envelopes, but what\n * they DO has to be one thing: a conversion that opened a second deal for\n * one person because two callers reached the same lead through two\n * implementations is the bug this file exists to make impossible. The doors\n * own their transport; this owns the writes.\n *\n * It lives here, in the tenancy runtime, rather than beside the plugin\n * route it came from, because the console's REST layer may not import a\n * feature plugin (the app→addons boundary), while both the plugin and the\n * console may import the runtime — which already holds the capture door\n * and the owner assignment this conversion goes through.\n *\n * ## Why a server function and not four client writes\n *\n * The lead is a host document the browser may edit, and a company or a deal\n * is a client-creatable org document. What the browser cannot do is CREATE A\n * CONTACT: the contacts collection is written only by `captureHostContact`\n * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`\n * fires), because that function is the dedupe — one human, one row, found by\n * normalized address across every site in the org — and the audience-band\n * gate. A client that wrote a contact document of its own would mint a\n * second row for a person the org already holds, and would do it past the\n * band.\n *\n * Once the contact has to come from the server, the rest follows it: the\n * conversion is one act with four writes, and a caller that made three of\n * them and lost the connection before the fourth leaves a lead marked\n * qualified with no contact, or a deal pointing at a contact that was never\n * stamped on the lead. Here the order is fixed — contact, then company, then\n * deal, then the lead — and the lead is stamped LAST, so a lead that carries\n * `convertedContactId` names a contact that exists.\n *\n * ## Idempotent on the lead\n *\n * A lead already carrying `convertedContactId` answers with the ids it has\n * and creates nothing more. A double-click on the dialog's button, a retry\n * after a slow response, or a Zapier zap replaying a step must not open a\n * second deal for one person.\n *\n * ## The actor\n *\n * A signed-in member converting from the console owns what nobody else\n * claimed: their uid is the owner of last resort, written directly rather\n * than through the deliberate reassignment, because they may be staff\n * converting on a workspace's behalf and a roster check would refuse the one\n * person who is actually here. An API key is not a person and is on no\n * roster, so for an `api` actor that last step is skipped and a contact\n * nobody chose an owner for — and no rule assigned — stays unowned, which\n * the console renders honestly as unassigned. Either way the caller has\n * already been authorized by its door; nothing here asks again.\n *\n * ## The deal carries the lead's lead source (AGL-3516)\n *\n * As Salesforce's conversion does, the deal opened here takes the lead's\n * `leadSource` — a label of the same picklist, already judged when the lead\n * was written — and its Type as the door judged it, or the org's default\n * Type when the converter named none. Its forecast category is its stage's.\n *\n * ## Every standard field lands where Salesforce's conversion puts it (AGL-3513)\n *\n * The contact's facet takes the salutation, the name's parts, the mobile,\n * the fax and Do not call. The company takes the account fields — industry,\n * rating, revenue and its currency, head count, website, phone, fax, the\n * address as its billing address, and the lead source as its Account\n * Source — whole when the conversion creates it, and only into its BLANK\n * fields when it links one the org already holds: a lead is one person's\n * account of the business, and the account record may know better. The\n * deal's Primary Campaign Source is the lead's most recent campaign — see\n * {@link leadLatestCampaignId}.\n */\n\nimport {\n consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n CRM_LEAD_STATUS_PICKLIST,\n crmLeadStatusLabelFor,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n CRM_COMPANY_PICKLIST_FIELDS,\n type CrmPicklist,\n type CrmPicklistId,\n crmPicklistDefaultLabel,\n judgeCrmCompanyPicklists,\n readCrmCompanyAccountFields,\n crmScopeTokens,\n dealStageForecastCategory,\n DEFAULT_DEAL_STAGES,\n grantedUnderConsentGroup,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n crmNewRecordListFields,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n restampCrmListFieldsAt,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { assignOwnerForCapture, notifyRecordAssigned } from './assign-contact-owner'\nimport { captureHostContact } from './capture-host-contact'\nimport { readCrmPicklist } from './read-picklist'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { crmActivityLinkWithCompany } from './crm-activity-company'\nimport { readContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\n\n/** Who is converting, as far as the writes need to know. */\nexport interface LeadConvertActor {\n /**\n * What `createdByUid` records on every record the conversion opens, and\n * who the audit line names. A member's uid, or the literal `'api'` for a\n * key — the same attribution the REST creates stamp.\n */\n uid: string\n email?: string | null\n /**\n * `member`: a signed-in person, who becomes the owner when nobody else\n * does. `api`: a key, which cannot own a record — see the header.\n */\n kind: 'member' | 'api'\n /**\n * For an `api` actor, the key's name — what the audit line attributes the\n * conversion to, since a key has no address to be named by.\n */\n apiKeyName?: string | null\n}\n\n/** What a conversion is asked to do. Validated by the door before it gets here. */\nexport interface ConvertHostLeadInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orgId: string\n /** The org document, as the door resolved it. */\n org: Record<string, unknown>\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n actor: LeadConvertActor\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those a member actor — somebody\n * converted this person, and a record with no owner is one nobody follows\n * up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. Already normalized. */\n createCompany?: { name: string; domain: string | null } | null\n /** Open a deal in the org's default pipeline. Already normalized. */\n deal?: {\n title: string\n amountCents: number | null\n /** Lowercase ISO 4217. */\n currency: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n /**\n * The deal's Type, as the door judged it against the org's\n * `opportunityType` list (AGL-3516); absent takes the list's default.\n */\n type?: string\n } | null\n}\n\n/**\n * Why a conversion did not happen. Each is a state of the data, not a fault\n * of the request — the door has already refused a malformed body — and each\n * leaves the lead unconverted so a retry after the remedy finds it where it\n * was.\n */\nexport type ConvertHostLeadRefusal =\n /** No document at `hosts/{hostId}/leads/{leadId}`. */\n | 'unknown-lead'\n /** The lead's address cannot become a contact. */\n | 'no-email'\n /**\n * The capture door produced no contact — it swallows its own failures and\n * drops a creation past a Free org's audience band. Nothing was written.\n */\n | 'contact-not-created'\n /**\n * The site holds an erasure for the lead's address (AGL-2623): the person\n * asked to be forgotten, and the capture door refuses to rebuild a record\n * for them — by a conversion as by a form. Nothing was written, and the\n * lead stays as it was; the erasure removes it when it runs.\n */\n | 'erased'\n /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */\n | 'band-full'\n /** `companyId` names no company in this organization. The contact stands. */\n | 'unknown-company'\n /** The default pipeline has no stages to open a deal in. The contact stands. */\n | 'no-stages'\n\nexport type ConvertHostLeadResult =\n | {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n }\n | { ok: false; reason: ConvertHostLeadRefusal }\n\n/** The stamp the conversion leaves on the lead — see `CrmLeadFields`. */\ntype LeadConversionStamp = Required<\n Pick<CrmLeadFields, 'status' | 'statusLabel' | 'convertedContactId' | 'convertedAtMs'>\n> &\n Pick<CrmLeadFields, 'dealId' | 'companyId' | 'ownerUid'>\n\n/**\n * The stage a new deal opens in.\n *\n * The caller's choice when the pipeline has it, otherwise the first OPEN\n * stage by order — a deal created by converting a lead is by definition not\n * yet won or lost, so defaulting to a closed stage would record an outcome\n * nobody reached. A pipeline with no open stage at all falls back to its\n * first stage rather than refusing: the merchant edited their pipeline into\n * that shape, and a conversion that failed because of it would read as a bug\n * in the lead.\n */\nexport function stageForNewDeal(\n pipeline: Pick<CrmPipeline, 'stages'>,\n requestedStageId: string | undefined,\n): CrmDealStage | null {\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n if (!stages.length) return null\n const requested = requestedStageId\n ? stages.find((stage) => stage.id === requestedStageId)\n : undefined\n return requested ?? stages.find((stage) => stage.kind === 'open') ?? stages[0]\n}\n\n/**\n * The lead's MOST RECENT campaign, which becomes the deal's Primary\n * Campaign Source as Salesforce's conversion makes it. A lead keeps its\n * campaigns as one `campaignIds` array that every writer extends with\n * `arrayUnion`, which appends — so the last id is the campaign the lead was\n * filed under last. `undefined` for a lead filed under none.\n */\nexport function leadLatestCampaignId(lead: Readonly<Record<string, unknown>>): string | undefined {\n const ids = readContainerIds(lead as Record<string, unknown>, 'campaign')\n return ids.length ? ids[ids.length - 1] : undefined\n}\n\n/** Whether a stored field holds nothing a fill would overwrite. */\nconst blankField = (value: unknown): boolean =>\n value === undefined ||\n value === null ||\n (typeof value === 'string' && !value.trim()) ||\n (typeof value === 'object' && !Array.isArray(value) && !Object.keys(value as object).length)\n\n/**\n * The account fields a lead hands its company (AGL-3513), normalized and\n * judged as every company door judges them: the lead's industry and rating\n * against the org's lists, its lead source as the Account Source, and its\n * revenue, currency, head count, fax, website, phone and address (as the\n * billing address). With `current` — a company the org already holds —\n * only the fields it leaves blank are answered, and the picklists it does\n * not name keep no default; without it, the company is being created and\n * each picklist the lead leaves empty starts from its list's default. A\n * value a list no longer holds is left behind rather than refusing the\n * conversion.\n */\nexport function leadCompanyFields(\n lead: Readonly<Record<string, unknown>>,\n lists: Readonly<Partial<Record<CrmPicklistId, CrmPicklist>>>,\n current: Readonly<Record<string, unknown>> | null,\n): Record<string, unknown> {\n const fill = (field: string) => !current || blankField(current[field])\n const offered = (value: unknown) => (blankField(value) ? undefined : value)\n const fields: Record<string, unknown> = {}\n const requested: Record<string, unknown> = {}\n const picklistSource: Record<string, unknown> = {\n industry: lead['industry'],\n rating: lead['rating'],\n accountSource: lead['leadSource'],\n }\n for (const { field } of CRM_COMPANY_PICKLIST_FIELDS) {\n const value = offered(picklistSource[field])\n if (value !== undefined && fill(field)) requested[field] = value\n }\n const picklists = judgeCrmCompanyPicklists(lists, requested, {\n current: current ?? {},\n created: !current,\n })\n for (const [field, value] of Object.entries(picklists.values)) {\n if (value && !picklists.errors[field]) fields[field] = value\n }\n const account: Record<string, unknown> = {}\n for (const field of ['annualRevenueCents', 'numberOfEmployees', 'fax'] as const) {\n const value = offered(lead[field])\n if (value !== undefined && fill(field)) account[field] = value\n }\n // The revenue's currency travels with the revenue it describes.\n if (account['annualRevenueCents'] !== undefined && offered(lead['currency']) !== undefined) {\n account['currency'] = lead['currency']\n }\n const read = readCrmCompanyAccountFields(account)\n for (const [field, value] of Object.entries(read.values)) {\n if (value !== null && value !== undefined && !read.errors[field]) fields[field] = value\n }\n // Already in the company's shapes: the lead normalized them as a company does.\n for (const [field, from] of [\n ['website', 'website'],\n ['phone', 'phone'],\n ['address', 'address'],\n ] as const) {\n const value = offered(lead[from])\n if (value !== undefined && fill(field)) fields[field] = value\n }\n return fields\n}\n\n/** The org's lists behind every company picklist field, for {@link leadCompanyFields}. */\nasync function readCompanyPicklists(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<Partial<Record<CrmPicklistId, CrmPicklist>>> {\n const ids = [...new Set(CRM_COMPANY_PICKLIST_FIELDS.map((entry) => entry.picklistId))]\n const lists = await Promise.all(ids.map((id) => readCrmPicklist(firestore, orgId, id)))\n return Object.fromEntries(ids.map((id, at) => [id, lists[at]]))\n}\n\n/** A stage's kind as the deal's denormalized status. */\nfunction dealStatusForStage(stage: CrmDealStage): CrmDealStatus {\n return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n}\n\n/**\n * Convert one lead. Throws only on infrastructure failure; every refusal\n * the data can produce is a `ConvertHostLeadResult`.\n */\nexport async function convertHostLead(\n input: ConvertHostLeadInput,\n): Promise<ConvertHostLeadResult> {\n const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input\n const requestedCompanyId = String(input.companyId ?? '').trim()\n // The lead's one home, on the org (AGL-3275). `orgId` is already resolved\n // by the caller, so this needs no second lookup to find it.\n const leadRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .doc(leadId)\n const leadSnapshot = await leadRef.get()\n if (!leadSnapshot.exists) return { ok: false, reason: 'unknown-lead' }\n const lead = (leadSnapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId) {\n return {\n ok: true,\n contactId: lead.convertedContactId,\n ...(lead.companyId ? { companyId: lead.companyId } : {}),\n ...(lead.dealId ? { dealId: lead.dealId } : {}),\n alreadyConverted: true,\n }\n }\n const email = normalizeContactEmail(lead['email'])\n if (!email) return { ok: false, reason: 'no-email' }\n\n const group = await consentGroupForSite(hostId, org)\n const visibleTo = crmScopeTokens(org, group)\n /*\n * What the CALLER may read, for the company-by-domain reuse below: their\n * group's own tokens plus `org`, exactly the set the console's listeners\n * filter on. A company outside it is one they could not open, so linking\n * a contact to it would point at a record the page then 404s on.\n */\n const readableTokens = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...consentGroupScope(group),\n ])\n /*\n * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else\n * whoever was already working the lead. Handed to the capture as the\n * facet's owner, which is what tells the capture's own assignment pass\n * to stand down — a person's choice outranks a rule. When nobody chose,\n * the facet names no owner and the pass runs the org's rules and the\n * site's default for a contact it creates (AGL-2618).\n */\n const pickedOwner = String(input.ownerUid ?? '').trim()\n const chosenOwner =\n pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '')\n const now = Date.now()\n const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined\n\n /*==========================================\n * 1. THE CONTACT — through the one door that dedupes and meters.\n *\n * `source: 'manual'` because a person converted this lead by hand, and\n * the source filter should say so; the interaction names the lead so the\n * contact's timeline can be walked back to what was captured. The facet\n * carries the stage and the owner, which is what makes this a sales\n * record rather than another form capture.\n *\n * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce\n * hands a lead's fields to the contact: phone, title, lead source, address, the\n * company as text (until step 2 links a record, whose own name then\n * replaces it), tags, and the marketing basis the lead recorded — the\n * person ticked the box on the lead, and a conversion must not be the\n * moment they lose it. Only what the lead holds is handed over; a value\n * the contact already carries is the contact's, because the capture door\n * writes a profile field only where the facet has none.\n *=========================================*/\n const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : []\n const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId))\n /*\n * WHICH SITES THE CONTACT'S GRANT COVERS (AGL-3320): the lead's, never\n * more. The capture door records a fresh grant, so it is handed the group's\n * disclosure key only when the lead ALREADY holds a grant given under the\n * group as it stands, at every site it names — a lead captured by one site\n * before the group existed, or before a site joined, converts to a contact\n * that site alone may mail, exactly as the lead was.\n */\n const leadDisclosure = grantedUnderConsentGroup(lead, group)\n ? consentGroupDisclosureKey(group)\n : null\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n // Who converted it, for the runs its contactCreated sets off (AGL-3376).\n actor:\n actor.kind === 'member'\n ? { kind: 'member', uid: actor.uid, email: actor.email ?? null }\n : { kind: 'apiKey', apiKeyName: actor.apiKeyName ?? null },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadConsent.basis === 'granted' && leadDisclosure\n ? { disclosedConsentGroup: leadDisclosure }\n : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n // Salesforce's Lead Source travels to the contact (AGL-3298).\n ...(typeof lead.leadSource === 'string' && lead.leadSource\n ? { leadSource: lead.leadSource }\n : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\n // Salesforce's standard lead fields (AGL-3513), as the contact keeps them.\n ...Object.fromEntries(\n (['salutation', 'firstName', 'lastName', 'mobilePhone', 'fax'] as const)\n .filter((key) => typeof lead[key] === 'string' && lead[key])\n .map((key) => [key, lead[key]]),\n ),\n // Only ever `true`: a lead that never said is not a person who asked to be called.\n ...(lead.doNotCall === true ? { doNotCall: true } : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The owner is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {\n await notifyRecordAssigned({\n hostId,\n orgId,\n ownerUid,\n actorUid: actor.kind === 'member' ? actor.uid : null,\n record: { kind: 'contact', id: contactId },\n who: leadName || email,\n })\n }\n\n /*==========================================\n * 2. THE COMPANY — linked, found by domain, or created.\n *=========================================*/\n let companyId: string | undefined\n /** The company the conversion found rather than made — its blanks are filled below. */\n let existingCompany: FirebaseFirestore.DocumentSnapshot | undefined\n if (requestedCompanyId) {\n const companySnapshot = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(requestedCompanyId)\n .get()\n if (!companySnapshot.exists) return { ok: false, reason: 'unknown-company' }\n companyId = requestedCompanyId\n existingCompany = companySnapshot\n } else if (createCompany) {\n /*\n * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its\n * company as text, and a company the org already files under that\n * name is the account, whatever domain it carries. Then the domain,\n * the key two contacts at one business share — a second company\n * document for `acme.com` would split the account the key exists to\n * join. Only a company the caller can see counts as found — see\n * `readableTokens`.\n */\n const visibleAmong = (\n snapshots: readonly FirebaseFirestore.QueryDocumentSnapshot[],\n ): FirebaseFirestore.QueryDocumentSnapshot | undefined =>\n snapshots.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n existingCompany = visibleAmong(byName.docs)\n if (!existingCompany && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n existingCompany = visibleAmong(byDomain.docs)\n }\n companyId = existingCompany?.id\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const company: Record<string, unknown> = {\n // The lead's account fields, whole (AGL-3513).\n ...leadCompanyFields(lead, await readCompanyPicklists(firestore, orgId), null),\n ...nameSearchFields(createCompany.name),\n ...(createCompany.domain ? { domain: createCompany.domain } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...company,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', company),\n })\n companyId = created.id\n }\n }\n if (companyId && existingCompany) {\n /*\n * A company the org already held takes the lead's account fields only\n * where it has none (AGL-3513) — and is restamped, because its\n * industry, rating and account source are what its list filters by.\n */\n const filled = leadCompanyFields(\n lead,\n await readCompanyPicklists(firestore, orgId),\n (existingCompany.data() ?? {}) as Record<string, unknown>,\n )\n if (Object.keys(filled).length) {\n await existingCompany.ref.update({ ...filled, updatedAt: FieldValue.serverTimestamp() })\n await restampCrmListFieldsAt(existingCompany.ref, 'companies')\n }\n }\n if (companyId) {\n /*\n * The one link writer (AGL-2613): the holder's facet field the contact\n * record reads, the queryable top-level mirror the company's contacts\n * card filters on, and the company's contacts count — planned from\n * the row as the capture left it, so a person the org already held\n * under another company is MOVED rather than counted twice.\n */\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: contactData,\n companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),\n groupId: group.groupId,\n companyId,\n })\n }\n\n /*==========================================\n * 3. THE DEAL — in the default pipeline, seeded if the org has none.\n *=========================================*/\n let dealId: string | undefined\n if (deal) {\n const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines)\n let pipelineId: string\n let pipeline: Pick<CrmPipeline, 'stages'>\n const defaults = await pipelinesRef\n .where('isDefault', '==', true)\n .limit(1)\n .get()\n if (!defaults.empty) {\n pipelineId = defaults.docs[0].id\n pipeline = defaults.docs[0].data() as CrmPipeline\n } else {\n /*\n * The deals section's own read shape, so the two agree on which\n * pipeline \"the org's pipeline\" is when none is flagged default: the\n * first by document id of a bounded window. Seeding a SECOND pipeline\n * here while one exists unflagged would leave the merchant with two\n * boards and their deals split between them.\n */\n const any = await pipelinesRef\n .orderBy(FieldPath.documentId())\n .limit(20)\n .get()\n if (!any.empty) {\n pipelineId = any.docs[0].id\n pipeline = any.docs[0].data() as CrmPipeline\n } else {\n // Exactly what the Deals section seeds: one `Sales` pipeline\n // carrying a COPY of the default stages, flagged default.\n const seeded: Omit<CrmPipeline, 'createdAt' | 'updatedAt'> = {\n name: 'Sales',\n stages: [...DEFAULT_DEAL_STAGES],\n isDefault: true,\n visibleTo,\n hostId,\n }\n const created = await pipelinesRef.add({\n ...seeded,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n pipelineId = created.id\n pipeline = seeded\n }\n }\n const stage = stageForNewDeal(pipeline, deal.stageId)\n if (!stage) return { ok: false, reason: 'no-stages' }\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const type =\n deal.type ??\n crmPicklistDefaultLabel(await readCrmPicklist(firestore, orgId, 'opportunityType')) ??\n undefined\n const leadSource = typeof lead.leadSource === 'string' ? lead.leadSource.trim() : ''\n // The Primary Campaign Source: the lead's most recent campaign (AGL-3513).\n const campaignId = leadLatestCampaignId(lead)\n const dealRecord: Record<string, unknown> = {\n title: deal.title,\n titleLower: deal.title.toLowerCase(),\n pipelineId,\n stageId: stage.id,\n status: dealStatusForStage(stage),\n ...(deal.amountCents !== null ? { amountCents: deal.amountCents } : {}),\n currency: deal.currency,\n stageChangedAtMs: now,\n forecastCategory: dealStageForecastCategory(stage),\n ...(type ? { type } : {}),\n ...(leadSource ? { leadSource } : {}),\n ...(campaignId ? { campaignId } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n contactId,\n // The converted person is the deal's Primary contact role, with no\n // role yet, as Salesforce's conversion makes them (AGL-3521).\n contactRoles: [{ contactId, primary: true }],\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n ...dealRecord,\n // What the Deals list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('deals', dealRecord),\n })\n dealId = created.id\n }\n\n /*==========================================\n * 4. THE LEAD — stamped last, once everything it names exists.\n *=========================================*/\n const stamp: LeadConversionStamp = {\n status: 'qualified',\n // The org's label for Qualified, beside the meaning (AGL-3512).\n statusLabel: crmLeadStatusLabelFor(\n await readCrmPicklist(firestore, orgId, CRM_LEAD_STATUS_PICKLIST),\n 'qualified',\n ),\n convertedContactId: contactId,\n convertedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n }\n await leadRef.update({ ...stamp, updatedAt: FieldValue.serverTimestamp() })\n\n /*\n * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the\n * contact, and every plugin with a listener — Sequences, for an\n * enrollment naming the lead — re-points its own records. After the\n * stamp, so a listener reading the lead finds the conversion on it; it\n * never throws. The activities roll up to the company this conversion\n * linked, else the one the contact was already filed under (AGL-4123).\n */\n const rollup = await crmActivityLinkWithCompany(\n orgRef,\n { contactId, ...(companyId ? { companyId } : {}) },\n { groupId: group.groupId, org },\n )\n await handOffLeadRecords({\n firestore,\n orgId,\n hostId,\n leadId,\n contactId,\n companyId: rollup.companyId,\n email,\n by: actor.kind === 'member' ? 'member' : 'api',\n })\n\n /*\n * The audit line, from the function that did the work (AGL-2622): the\n * conversion is one act however many records it opened, so it is one\n * entry, on the lead, in the feed of the site that holds the lead. A\n * repeat call answered above with the ids it already had wrote nothing\n * and logs nothing.\n */\n await logHostActivity(\n hostId,\n {\n uid: actor.uid,\n email: actor.email ?? null,\n ...(actor.apiKeyName ? { apiKeyName: actor.apiKeyName } : {}),\n },\n 'Converted lead',\n { type: 'lead', id: leadId, name: String(lead['name'] ?? '') || email },\n )\n\n return {\n ok: true,\n contactId,\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n alreadyConverted: false,\n }\n}\n"],"names":["consentGroupDisclosureKey","consentGroupScope","CRM_COLLECTIONS","CRM_LEAD_STATUS_PICKLIST","crmLeadStatusLabelFor","CRM_COMPANY_PICKLIST_FIELDS","crmPicklistDefaultLabel","judgeCrmCompanyPicklists","readCrmCompanyAccountFields","crmScopeTokens","dealStageForecastCategory","DEFAULT_DEAL_STAGES","grantedUnderConsentGroup","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","crmNewRecordListFields","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","restampCrmListFieldsAt","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","readCrmPicklist","handOffLeadRecords","crmActivityLinkWithCompany","readContainerIds","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","leadLatestCampaignId","lead","ids","blankField","value","trim","Array","isArray","Object","keys","leadCompanyFields","lists","current","fill","field","offered","fields","picklistSource","industry","rating","accountSource","picklists","created","entries","values","errors","account","read","from","readCompanyPicklists","firestore","orgId","Set","map","entry","picklistId","Promise","all","fromEntries","at","dealStatusForStage","convertHostLead","input","leadSnapshot","actor","contactSnapshot","hostId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","leadTags","tags","leadConsent","leadDisclosure","captured","name","source","interaction","summary","refId","uid","apiKeyName","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","filter","key","doNotCall","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","outcome","update","updatedAt","serverTimestamp","record","who","existingCompany","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","createdByUid","createdAt","add","filled","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","type","campaignId","dealRecord","title","titleLower","status","amountCents","currency","stageChangedAtMs","forecastCategory","contactRoles","primary","deals","stamp","statusLabel","convertedAtMs","rollup","by"],"mappings":"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"}
@@ -40,9 +40,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
40
40
  *
41
41
  * Never throws: the capture that made the contact has already happened,
42
42
  * and a lead that could not be stamped is one somebody converts by hand.
43
- */ import { CRM_LEAD_STATUS_PICKLIST, crmLeadStatusLabelFor, isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
43
+ */ import { consentGroupForHost, CRM_LEAD_STATUS_PICKLIST, crmLeadStatusLabelFor, isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
44
44
  import { firebaseAdmin, getOrgForHost, leadForWrite } from "@aglyn/tenant-data-admin";
45
45
  import { handOffLeadRecords } from "@aglyn/tenant-runtime/hand-off-lead";
46
+ import { crmActivityLinkWithCompany } from "./crm-activity-company.js";
46
47
  import { FieldValue } from "firebase-admin/firestore";
47
48
  import { readCrmPicklist } from "./read-picklist.js";
48
49
  /**
@@ -81,12 +82,18 @@ import { readCrmPicklist } from "./read-picklist.js";
81
82
  merge: true
82
83
  });
83
84
  if (resolved) {
85
+ const { companyId } = await crmActivityLinkWithCompany(firestore.collection('orgs').doc(resolved.orgId), {
86
+ contactId: input.contactId
87
+ }, {
88
+ groupId: consentGroupForHost(resolved.org, input.hostId).groupId
89
+ });
84
90
  await handOffLeadRecords({
85
91
  firestore,
86
92
  orgId: resolved.orgId,
87
93
  hostId: input.hostId,
88
94
  leadId: key,
89
95
  contactId: input.contactId,
96
+ companyId,
90
97
  email,
91
98
  by: input.by
92
99
  });
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.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 * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n CRM_LEAD_STATUS_PICKLIST,\n type CrmLeadFields,\n crmLeadStatusLabelFor,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost, leadForWrite } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { readCrmPicklist } from './read-picklist'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n /*\n * The org row, with any not-yet-backfilled predecessor carried onto it\n * first (AGL-3275) — a conversion must not stamp `convertedContactId` on\n * a host row that a later capture would then write past on the org.\n */\n const { ref: leadRef } = await leadForWrite(input.hostId, key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n // The org's label for Qualified, beside the meaning (AGL-3512).\n const resolved = await getOrgForHost(input.hostId)\n const statuses = resolved\n ? await readCrmPicklist(firestore, resolved.orgId, CRM_LEAD_STATUS_PICKLIST)\n : null\n await leadRef.set(\n {\n status: 'qualified',\n ...(statuses ? { statusLabel: crmLeadStatusLabelFor(statuses, 'qualified') } : {}),\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n if (resolved) {\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["CRM_LEAD_STATUS_PICKLIST","crmLeadStatusLabelFor","isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","leadForWrite","handOffLeadRecords","FieldValue","readCrmPicklist","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","ref","leadRef","hostId","get","exists","lead","data","convertedContactId","resolved","statuses","orgId","set","status","statusLabel","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","leadId","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,wBAAwB,EAExBC,qBAAqB,EACrBC,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,EAAEC,YAAY,QAAQ,2BAA0B;AACrF,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,eAAe,QAAQ,qBAAiB;AAajD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQV,sBAAsBS,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQT,UAAUS,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YAUYC;QATd,MAAMC,YAAYZ,cAAca,GAAG,GAAGD,SAAS;QAC/C;;;;KAIC,GACD,MAAM,EAAEE,KAAKC,OAAO,EAAE,GAAG,MAAMb,aAAaK,MAAMS,MAAM,EAAEP;QAC1D,MAAME,WAAW,MAAMI,QAAQE,GAAG;QAClC,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QAClC,IAAIQ,KAAKE,kBAAkB,IAAI,CAACxB,cAAcsB,OAAO,OAAO;QAC5D,gEAAgE;QAChE,MAAMG,WAAW,MAAMrB,cAAcM,MAAMS,MAAM;QACjD,MAAMO,WAAWD,WACb,MAAMjB,gBAAgBO,WAAWU,SAASE,KAAK,EAAE7B,4BACjD;QACJ,MAAMoB,QAAQU,GAAG,CACf;YACEC,QAAQ;WACJH,WAAW;YAAEI,aAAa/B,sBAAsB2B,UAAU;QAAa,IAAI,CAAC;YAChFF,oBAAoBd,MAAMG,SAAS;YACnCkB,eAAeC,KAAKC,GAAG;YACvBC,aAAaxB,MAAMyB,EAAE;YACrBC,WAAW7B,WAAW8B,eAAe;YAEvC;YAAEC,OAAO;QAAK;QAEhB,IAAIb,UAAU;YACZ,MAAMnB,mBAAmB;gBACvBS;gBACAY,OAAOF,SAASE,KAAK;gBACrBR,QAAQT,MAAMS,MAAM;gBACpBoB,QAAQ3B;gBACRC,WAAWH,MAAMG,SAAS;gBAC1BF;gBACAwB,IAAIzB,MAAMyB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqC9B,MAAMS,MAAM,EAAEqB;QACjE,OAAO;IACT;AACF;AAEA,eAAe/B,2BAA0B"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.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 * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n consentGroupForHost,\n CRM_LEAD_STATUS_PICKLIST,\n type CrmLeadFields,\n crmLeadStatusLabelFor,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost, leadForWrite } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { crmActivityLinkWithCompany } from './crm-activity-company'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { readCrmPicklist } from './read-picklist'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n /*\n * The org row, with any not-yet-backfilled predecessor carried onto it\n * first (AGL-3275) — a conversion must not stamp `convertedContactId` on\n * a host row that a later capture would then write past on the org.\n */\n const { ref: leadRef } = await leadForWrite(input.hostId, key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n // The org's label for Qualified, beside the meaning (AGL-3512).\n const resolved = await getOrgForHost(input.hostId)\n const statuses = resolved\n ? await readCrmPicklist(firestore, resolved.orgId, CRM_LEAD_STATUS_PICKLIST)\n : null\n await leadRef.set(\n {\n status: 'qualified',\n ...(statuses ? { statusLabel: crmLeadStatusLabelFor(statuses, 'qualified') } : {}),\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n if (resolved) {\n const { companyId } = await crmActivityLinkWithCompany(\n firestore.collection('orgs').doc(resolved.orgId),\n { contactId: input.contactId },\n { groupId: consentGroupForHost(resolved.org, input.hostId).groupId },\n )\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n companyId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["consentGroupForHost","CRM_LEAD_STATUS_PICKLIST","crmLeadStatusLabelFor","isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","leadForWrite","handOffLeadRecords","crmActivityLinkWithCompany","FieldValue","readCrmPicklist","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","ref","leadRef","hostId","get","exists","lead","data","convertedContactId","resolved","statuses","orgId","set","status","statusLabel","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","companyId","collection","doc","groupId","org","leadId","error","console"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SACEA,mBAAmB,EACnBC,wBAAwB,EAExBC,qBAAqB,EACrBC,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,EAAEC,YAAY,QAAQ,2BAA0B;AACrF,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,0BAA0B,QAAQ,4BAAwB;AACnE,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,eAAe,QAAQ,qBAAiB;AAajD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQX,sBAAsBU,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQV,UAAUU,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YAUYC;QATd,MAAMC,YAAYb,cAAcc,GAAG,GAAGD,SAAS;QAC/C;;;;KAIC,GACD,MAAM,EAAEE,KAAKC,OAAO,EAAE,GAAG,MAAMd,aAAaM,MAAMS,MAAM,EAAEP;QAC1D,MAAME,WAAW,MAAMI,QAAQE,GAAG;QAClC,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QAClC,IAAIQ,KAAKE,kBAAkB,IAAI,CAACzB,cAAcuB,OAAO,OAAO;QAC5D,gEAAgE;QAChE,MAAMG,WAAW,MAAMtB,cAAcO,MAAMS,MAAM;QACjD,MAAMO,WAAWD,WACb,MAAMjB,gBAAgBO,WAAWU,SAASE,KAAK,EAAE9B,4BACjD;QACJ,MAAMqB,QAAQU,GAAG,CACf;YACEC,QAAQ;WACJH,WAAW;YAAEI,aAAahC,sBAAsB4B,UAAU;QAAa,IAAI,CAAC;YAChFF,oBAAoBd,MAAMG,SAAS;YACnCkB,eAAeC,KAAKC,GAAG;YACvBC,aAAaxB,MAAMyB,EAAE;YACrBC,WAAW7B,WAAW8B,eAAe;YAEvC;YAAEC,OAAO;QAAK;QAEhB,IAAIb,UAAU;YACZ,MAAM,EAAEc,SAAS,EAAE,GAAG,MAAMjC,2BAC1BS,UAAUyB,UAAU,CAAC,QAAQC,GAAG,CAAChB,SAASE,KAAK,GAC/C;gBAAEd,WAAWH,MAAMG,SAAS;YAAC,GAC7B;gBAAE6B,SAAS9C,oBAAoB6B,SAASkB,GAAG,EAAEjC,MAAMS,MAAM,EAAEuB,OAAO;YAAC;YAErE,MAAMrC,mBAAmB;gBACvBU;gBACAY,OAAOF,SAASE,KAAK;gBACrBR,QAAQT,MAAMS,MAAM;gBACpByB,QAAQhC;gBACRC,WAAWH,MAAMG,SAAS;gBAC1B0B;gBACA5B;gBACAwB,IAAIzB,MAAMyB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOU,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqCnC,MAAMS,MAAM,EAAE0B;QACjE,OAAO;IACT;AACF;AAEA,eAAepC,2BAA0B"}
@@ -0,0 +1,25 @@
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 } from '@aglyn/aglyn/app-utils/crm';
18
+ import { type CrmActivityCompanySources, type StampedCrmActivityLink } from '../model/crm-activity-link';
19
+ /**
20
+ * `link` with its Account stamped, for a writer on the server (AGL-4123): the
21
+ * one rollup of `model/crm-activity-link.ts`, over the Admin SDK's reads. A
22
+ * writer that already read the deal or the contact hands it over in `sources`
23
+ * and pays for neither. Never throws; see `resolveCrmActivityCompany`.
24
+ */
25
+ export declare function crmActivityLinkWithCompany<Link extends CrmActivityLink>(orgRef: FirebaseFirestore.DocumentReference, link: Link, sources?: CrmActivityCompanySources): Promise<StampedCrmActivityLink<Link>>;
@@ -0,0 +1,34 @@
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 { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
17
+ import { resolveCrmActivityCompany } from "../model/crm-activity-link.js";
18
+ /**
19
+ * `link` with its Account stamped, for a writer on the server (AGL-4123): the
20
+ * one rollup of `model/crm-activity-link.ts`, over the Admin SDK's reads. A
21
+ * writer that already read the deal or the contact hands it over in `sources`
22
+ * and pays for neither. Never throws; see `resolveCrmActivityCompany`.
23
+ */ export function crmActivityLinkWithCompany(orgRef, link, sources = {}) {
24
+ const read = async (collection, id)=>{
25
+ var _data;
26
+ return (_data = (await orgRef.collection(collection).doc(id).get()).data()) != null ? _data : null;
27
+ };
28
+ return resolveCrmActivityCompany(link, sources, {
29
+ deal: (id)=>read(CRM_COLLECTIONS.deals, id),
30
+ contact: (id)=>read('contacts', id)
31
+ });
32
+ }
33
+
34
+ //# sourceMappingURL=crm-activity-company.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/crm-activity-company.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 { CRM_COLLECTIONS, type CrmActivityLink } from '@aglyn/aglyn/app-utils/crm'\nimport {\n type CrmActivityCompanySources,\n resolveCrmActivityCompany,\n type StampedCrmActivityLink,\n} from '../model/crm-activity-link'\n\n/**\n * `link` with its Account stamped, for a writer on the server (AGL-4123): the\n * one rollup of `model/crm-activity-link.ts`, over the Admin SDK's reads. A\n * writer that already read the deal or the contact hands it over in `sources`\n * and pays for neither. Never throws; see `resolveCrmActivityCompany`.\n */\nexport function crmActivityLinkWithCompany<Link extends CrmActivityLink>(\n orgRef: FirebaseFirestore.DocumentReference,\n link: Link,\n sources: CrmActivityCompanySources = {},\n): Promise<StampedCrmActivityLink<Link>> {\n const read = async (collection: string, id: string) =>\n (await orgRef.collection(collection).doc(id).get()).data() ?? null\n return resolveCrmActivityCompany(link, sources, {\n deal: (id) => read(CRM_COLLECTIONS.deals, id),\n contact: (id) => read('contacts', id),\n })\n}\n"],"names":["CRM_COLLECTIONS","resolveCrmActivityCompany","crmActivityLinkWithCompany","orgRef","link","sources","read","collection","id","doc","get","data","deal","deals","contact"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,eAAe,QAA8B,6BAA4B;AAClF,SAEEC,yBAAyB,QAEpB,gCAA4B;AAEnC;;;;;CAKC,GACD,OAAO,SAASC,2BACdC,MAA2C,EAC3CC,IAAU,EACVC,UAAqC,CAAC,CAAC;IAEvC,MAAMC,OAAO,OAAOC,YAAoBC;YACtC;gBAAA,QAAA,AAAC,CAAA,MAAML,OAAOI,UAAU,CAACA,YAAYE,GAAG,CAACD,IAAIE,GAAG,EAAC,EAAGC,IAAI,cAAxD,QAA8D;;IAChE,OAAOV,0BAA0BG,MAAMC,SAAS;QAC9CO,MAAM,CAACJ,KAAOF,KAAKN,gBAAgBa,KAAK,EAAEL;QAC1CM,SAAS,CAACN,KAAOF,KAAK,YAAYE;IACpC;AACF"}
@@ -14,12 +14,14 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
14
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
- */ import { consentGroupForHost, contactCaptureHostIds, crmActivityLogHasRoom, crmScopeTokens, emailAddressOf, emailDomainOf, emailExcerpt, findMemberByEmailAddress, forwardedSection, htmlToPlainText, personKey, readContactFacet } from "@aglyn/aglyn/server";
17
+ */ import { consentGroupForHost, contactCaptureHostIds, crmActivityLogHasRoom, crmScopeTokens, emailAddressOf, emailDomainOf, emailExcerpt, findMemberByEmailAddress, forwardedSection, htmlToPlainText, personKey } from "@aglyn/aglyn/server";
18
18
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
19
19
  import { findContactByEmail } from "@aglyn/tenant-data-admin/server/contact-email-index";
20
20
  import { countCrmActivitiesForRecord } from "@aglyn/tenant-data-admin/server/crm-records";
21
21
  import { buildCrmCapturedEmailActivity, crmCapturedEmailKey, crmInboundCandidates, isCrmInboundAddress, isCrmInboundToken, mintCrmInboundToken } from "../model/crm-inbound.js";
22
22
  import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef } from "./crm-email-activity.js";
23
+ import { crmActivityLinkWithCompany } from "./crm-activity-company.js";
24
+ import { stampCrmActivityCompany } from "../model/crm-activity-link.js";
23
25
  /** The most candidate addresses one message is matched by. */ export const CRM_INBOUND_CANDIDATE_MAX = 6;
24
26
  /** The most sites whose leads are consulted for one message. */ export const CRM_INBOUND_LEAD_HOST_MAX = 50;
25
27
  /**
@@ -109,16 +111,15 @@ import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef
109
111
  var _contact_data, _record_hostId;
110
112
  const record = (_contact_data = contact.data()) != null ? _contact_data : {};
111
113
  const hostId = String((_record_hostId = record['hostId']) != null ? _record_hostId : '').trim() || contactCaptureHostIds(record)[0] || '';
112
- const link = {
114
+ // The company the capturing site files this person under, so the row
115
+ // shows on the company's log as a sent email's does.
116
+ const link = stampCrmActivityCompany({
113
117
  contactId: contact.id
114
- };
115
- if (hostId) {
116
- // The company the capturing site files this person under, so the
117
- // row shows on the company's log as a sent email's does.
118
- const group = consentGroupForHost(org, hostId);
119
- const facet = readContactFacet(record, group.groupId);
120
- if (facet.companyId) link.companyId = facet.companyId;
121
- }
118
+ }, {
119
+ contact: record,
120
+ groupId: hostId ? consentGroupForHost(org, hostId).groupId : null,
121
+ org
122
+ });
122
123
  return {
123
124
  email: candidate.email,
124
125
  direction: candidate.direction,
@@ -143,12 +144,17 @@ import { createCrmEmailActivity, crmCapturedEmailActivityRef, newCrmActivityRef
143
144
  var _lead_get;
144
145
  if (!lead.exists) break;
145
146
  if (!visibleToHost(lead.get('visibleTo'), hostId)) continue;
146
- const link = {
147
- leadId: lead.id
148
- };
149
- // A converted lead's mail belongs on the contact it became as well.
147
+ // A converted lead's mail belongs on the contact it became as well, and
148
+ // on that contact's company.
150
149
  const converted = String((_lead_get = lead.get('convertedContactId')) != null ? _lead_get : '').trim();
151
- if (converted) link.contactId = converted;
150
+ const link = await crmActivityLinkWithCompany(orgRef, _extends({
151
+ leadId: lead.id
152
+ }, converted ? {
153
+ contactId: converted
154
+ } : {}), {
155
+ groupId: consentGroupForHost(org, hostId).groupId,
156
+ org
157
+ });
152
158
  return {
153
159
  email: candidate.email,
154
160
  direction: candidate.direction,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/crm-inbound-email.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 consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n type CrmActivityLink,\n type CrmEmailDirection,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n emailExcerpt,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n personKey,\n readContactFacet,\n type RosterMember,\n} from '@aglyn/aglyn/server'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport {\n buildCrmCapturedEmailActivity,\n crmCapturedEmailKey,\n type CrmInboundCandidate,\n crmInboundCandidates,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n} from '../model/crm-inbound'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from './crm-email-activity'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadMemberAddressRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/**\n * The workspace's email capture address, as the org document carries it\n * under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n *\n * The CRM's field, not the platform's: the organization type does not name\n * it, and this module is its only reader and writer. Server-owned and\n * member-readable: the token IS the address, so a client that could write\n * it could point another workspace's mail here.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n const link: CrmActivityLink = { contactId: contact.id }\n if (hostId) {\n // The company the capturing site files this person under, so the\n // row shows on the company's log as a sent email's does.\n const group = consentGroupForHost(org, hostId)\n const facet = readContactFacet(record, group.groupId)\n if (facet.companyId) link.companyId = facet.companyId\n }\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n const link: CrmActivityLink = { leadId: lead.id }\n // A converted lead's mail belongs on the contact it became as well.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n if (converted) link.contactId = converted\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadMemberAddressRoster`: sign-in addresses and confirmed aliases. */\n members: readonly RosterMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: emailExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmScopeTokens","emailAddressOf","emailDomainOf","emailExcerpt","findMemberByEmailAddress","forwardedSection","htmlToPlainText","personKey","readContactFacet","visibleToHost","findContactByEmail","countCrmActivitiesForRecord","buildCrmCapturedEmailActivity","crmCapturedEmailKey","crmInboundCandidates","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","group","facet","groupId","companyId","direction","kind","visibleTo","key","lead","hostIds","leadId","converted","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","members","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,mBAAmB,EACnBC,qBAAqB,EACrBC,qBAAqB,EAGrBC,cAAc,EACdC,cAAc,EACdC,aAAa,EACbC,YAAY,EACZC,wBAAwB,EACxBC,gBAAgB,EAChBC,eAAe,EACfC,SAAS,EACTC,gBAAgB,QAEX,sBAAqB;AAE5B,SAASC,aAAa,QAAQ,sCAAqC;AACnE,SAASC,kBAAkB,QAAQ,sDAAqD;AACxF,SAASC,2BAA2B,QAAQ,8CAA6C;AACzF,SACEC,6BAA6B,EAC7BC,mBAAmB,EAEnBC,oBAAoB,EACpBC,mBAAmB,EACnBC,iBAAiB,EACjBC,mBAAmB,QACd,0BAAsB;AAC7B,SACEC,sBAAsB,EACtBC,2BAA2B,EAC3BC,iBAAiB,QACZ,0BAAsB;AA2C7B,4DAA4D,GAC5D,OAAO,MAAMC,4BAA4B,EAAC;AAE1C,8DAA8D,GAC9D,OAAO,MAAMC,4BAA4B,GAAE;AAU3C;;;;;CAKC,GACD,OAAO,eAAeC,sBACpBC,SAAsC,EACtCC,KAAa,EACbC,UAAgD,CAAC,CAAC;QAGpCA;IADd,MAAMC,SAASD,QAAQC,MAAM,KAAK;IAClC,MAAMC,SAAQF,iBAAAA,QAAQE,KAAK,YAAbF,iBAAiBG,KAAKC,GAAG;IACvC,MAAMC,SAASP,UAAUQ,UAAU,CAAC,QAAQC,GAAG,CAACR;IAChD,OAAOD,UAAUU,cAAc,CAAC,OAAOC;YAGpBC;QAFjB,MAAMA,WAAW,MAAMD,GAAGE,GAAG,CAACN;QAC9B,IAAI,CAACK,SAASE,MAAM,EAAE,MAAM,IAAIC,MAAM,CAAC,0BAA0B,EAAEd,OAAO;QAC1E,MAAMe,WAAWJ,gBAAAA,SAASC,GAAG,CAAC,yBAAbD,gBAA8B;QAC/C,MAAMK,OAAOzB,kBAAkBwB,2BAAAA,QAASE,KAAK;QAC7C,IAAID,QAAQ,CAACd,QAAQ;YACnB,MAAMgB,cAAcC,OAAOJ,2BAAAA,QAASG,WAAW;YAC/C,OAAO;gBACLD,OAAOG,OAAOL,2BAAAA,QAASE,KAAK;gBAC5BC,aAAaC,OAAOE,QAAQ,CAACH,gBAAgBA,cAAc,IAAIA,cAAcf;eACzE,QAAOY,2BAAAA,QAASO,WAAW,MAAK,WAAW;gBAAEA,aAAaP,QAAQO,WAAW;YAAC,IAAI,CAAC;gBACvFC,SAAS;;QAEb;QACA,MAAML,cACJF,QAAQG,OAAOE,QAAQ,CAACF,OAAOJ,2BAAAA,QAASG,WAAW,MAAMC,OAAOJ,2BAAAA,QAASG,WAAW,IAAI,IACpFC,OAAOJ,2BAAAA,QAASG,WAAW,IAC3Bf;QACN,MAAMqB,OAAsB;YAC1BP,OAAOzB;YACP0B;WACIF,OAAO;YAAEM,aAAanB;QAAM,IAAI,CAAC;QAEvCO,GAAGe,MAAM,CAACnB,QAAQ;YAAEoB,YAAYF;QAAK;QACrC,OAAO,aAAKA;YAAMD,SAASP;;IAC7B;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeW,yBACpB5B,SAAsC,EACtCkB,KAAc;QAUkCT;IARhD,IAAI,CAACjB,kBAAkB0B,QAAQ,OAAO;IACtC,MAAMW,OAAO,MAAM7B,UAChBQ,UAAU,CAAC,QACXsB,KAAK,CAAC,oBAAoB,MAAMZ,OAChCa,KAAK,CAAC,GACNlB,GAAG;IACN,MAAMJ,MAAMoB,KAAKG,IAAI,CAAC,EAAE;IACxB,IAAI,CAACvB,KAAK,OAAO;IACjB,OAAO;QAAER,OAAOQ,IAAIwB,EAAE;QAAEC,KAAK;YAAEC,KAAK1B,IAAIwB,EAAE;YAAMxB,YAAAA,IAAI2B,IAAI,cAAR3B,YAAc,CAAC;IAAK;AACtE;AAEA;;;CAGC,GACD,OAAO,eAAe4B,kBACpBrC,SAAsC,EACtCC,KAAa;IAEb,MAAM4B,OAAO,MAAM7B,UAChBQ,UAAU,CAAC,SACXsB,KAAK,CAAC,SAAS,MAAM7B,OACrB8B,KAAK,CAACjC,2BACNe,GAAG;IACN,OAAOgB,KAAKG,IAAI,CAACM,GAAG,CAAC,CAAC7B,MAAQY,OAAOZ,IAAIwB,EAAE;AAC7C;AAeA;;;;;;CAMC,GACD,OAAO,eAAeM,6BACpBvC,SAAsC,EACtCwC,KAKC;IAED,MAAM,EAAEvC,KAAK,EAAEiC,GAAG,EAAE,GAAGM;IACvB,MAAMjC,SAASP,UAAUQ,UAAU,CAAC,QAAQC,GAAG,CAACR;IAChD,MAAMwC,WAAWlC,OAAOC,UAAU,CAAC;IACnC,MAAMkC,WAAW,CAACC;QAChB,MAAMC,MAAMD,MAAM,CAAC,YAAY;QAC/B,OAAOE,MAAMC,OAAO,CAACF,OAAOA,IAAIN,GAAG,CAAC,CAACpB,QAAUG,OAAOH,UAAU,EAAE;IACpE;IACA,KAAK,MAAM6B,aAAaP,MAAMQ,UAAU,CAACC,KAAK,CAAC,GAAGpD,2BAA4B;QAC5E,MAAMqD,UAAU,MAAMhE,mBAAmBuD,UAAUM,UAAUI,KAAK;QAClE,IAAID,SAAS;gBACKA,eAEPP;YAFT,MAAMA,UAAUO,gBAAAA,QAAQd,IAAI,cAAZc,gBAAkB,CAAC;YACnC,MAAME,SACJ/B,QAAOsB,iBAAAA,MAAM,CAAC,SAAS,YAAhBA,iBAAoB,IAAIU,IAAI,MAAM/E,sBAAsBqE,OAAO,CAAC,EAAE,IAAI;YAC/E,MAAMW,OAAwB;gBAAEC,WAAWL,QAAQjB,EAAE;YAAC;YACtD,IAAImB,QAAQ;gBACV,iEAAiE;gBACjE,yDAAyD;gBACzD,MAAMI,QAAQnF,oBAAoB6D,KAAKkB;gBACvC,MAAMK,QAAQzE,iBAAiB2D,QAAQa,MAAME,OAAO;gBACpD,IAAID,MAAME,SAAS,EAAEL,KAAKK,SAAS,GAAGF,MAAME,SAAS;YACvD;YACA,OAAO;gBACLR,OAAOJ,UAAUI,KAAK;gBACtBS,WAAWb,UAAUa,SAAS;gBAC9BC,MAAM;gBACNP;gBACAF;gBACAU,WAAWpB,SAASC;YACtB;QACF;QACA,MAAMoB,MAAMhF,UAAUgE,UAAUI,KAAK;QACrC,IAAI,CAACY,KAAK;QACV;;;;;;;;KAQC,GACD,MAAMC,OAAO,MAAMzD,OAAOC,UAAU,CAAC,SAASC,GAAG,CAACsD,KAAKlD,GAAG;QAC1D,KAAK,MAAMuC,UAAUZ,MAAMyB,OAAO,CAAChB,KAAK,CAAC,GAAGnD,2BAA4B;gBAK7CkE;YAJzB,IAAI,CAACA,KAAKlD,MAAM,EAAE;YAClB,IAAI,CAAC7B,cAAc+E,KAAKnD,GAAG,CAAC,cAAsCuC,SAAS;YAC3E,MAAME,OAAwB;gBAAEY,QAAQF,KAAK/B,EAAE;YAAC;YAChD,oEAAoE;YACpE,MAAMkC,YAAY9C,QAAO2C,YAAAA,KAAKnD,GAAG,CAAC,iCAATmD,YAAkC,IAAIX,IAAI;YACnE,IAAIc,WAAWb,KAAKC,SAAS,GAAGY;YAChC,OAAO;gBACLhB,OAAOJ,UAAUI,KAAK;gBACtBS,WAAWb,UAAUa,SAAS;gBAC9BC,MAAM;gBACNP;gBACAF;gBACA,kEAAkE;gBAClE,4CAA4C;gBAC5CU,WAAWtF,eAAe0D,KAAK7D,oBAAoB6D,KAAKkB;YAC1D;QACF;IACF;IACA,OAAO;AACT;AAYA;;;;;;;;;;CAUC,GACD,OAAO,eAAegB,oBACpBpE,SAAsC,EACtCwC,KASC;cAkDM6B;IAhDP,MAAM,EAAEpE,KAAK,EAAEiC,GAAG,EAAEmC,OAAO,EAAEC,MAAM,EAAE,GAAG9B;IACxC,MAAM+B,QAAQ,AAACF,CAAAA,QAAQG,IAAI,CAACnB,IAAI,KAAKgB,QAAQG,IAAI,GAAG1F,gBAAgBuF,QAAQI,IAAI,CAAA,EAAGC,OAAO,CACxF,UACA;IAEF,MAAMC,YAAY9F,iBAAiB0F;IACnC,MAAM,EAAEK,MAAM,EAAEC,cAAc,EAAE7B,UAAU,EAAE,GAAG1D,qBAAqB;QAClEwF,MAAMT,QAAQS,IAAI;QAClBC,IAAI;eAAIV,QAAQU,EAAE;eAAKV,QAAQW,WAAW;SAAC;QAC3CC,IAAI;eAAIZ,QAAQY,EAAE;eAAKZ,QAAQa,GAAG;SAAC;QACnCC,aAAa,UAAER,6BAAAA,UAAWG,IAAI,mBAAIM;QAClCd;QACAe,SAAS7C,MAAM6C,OAAO;IACxB;IACA,MAAMC,eAAe5G,cAAc2F,QAAQS,IAAI;IAC/C,IAAI,CAAC9B,WAAWuC,MAAM,EAAE,OAAO;QAAEC,SAAS;QAAaF;IAAa;IAEpE,MAAMG,QAAQ,MAAMlD,6BAA6BvC,WAAW;QAC1DC;QACAiC;QACAc;QACAiB,SAASzB,MAAMyB,OAAO;IACxB;IACA,IAAI,CAACwB,OAAO,OAAO;QAAED,SAAS;QAAaF;IAAa;IAExD,MAAMvB,MAAM1E,oBAAoBgF,QAAQqB,SAAS,EAAErB,QAAQpC,EAAE;IAC7D,MAAM0D,MAAM5B,MACRpE,4BAA4BK,WAAWC,OAAO8D,OAC9CnE,kBAAkBI,WAAWC;IACjC,IAAI8D,KAAK;QACP,MAAM6B,WAAW,MAAMD,IAAI9E,GAAG;QAC9B,IAAI+E,SAAS9E,MAAM,EAAE,OAAO;YAAE0E,SAAS;YAAaK,YAAYF,IAAI1D,EAAE;QAAC;IACzE;IAEA,MAAM1B,SAASP,UAAUQ,UAAU,CAAC,QAAQC,GAAG,CAACR;IAChD,IAAI,CAAC1B,sBAAsB,MAAMY,4BAA4BoB,QAAQkF,MAAMnC,IAAI,IAAI;QACjF,OAAO;YAAEkC,SAAS;YAAWC;QAAM;IACrC;IAEA,0EAA0E;IAC1E,qEAAqE;IACrE,MAAMK,SAASjB,iBAAiBjG,yBAAyB4D,MAAM6C,OAAO,EAAET,UAAU;IAClF,wEAAwE;IACxE,wEAAwE;IACxE,uBAAuB;IACvB,MAAMG,KACJU,MAAM7B,SAAS,KAAK,aAChB6B,MAAMtC,KAAK,IACVkB,uBAAAA,QAAQU,EAAE,CACRzC,GAAG,CAAC,CAACyD,QAAUtH,eAAesH,QAC9BC,IAAI,CAAC,CAACD,QAAUA,SAAS,CAACxG,oBAAoBwG,OAAOzB,oBAFvDD,uBAEmE;IAC1E,MAAM4B,WAAW7G,8BAA8B;QAC7CwE,WAAW6B,MAAM7B,SAAS;QAC1BsC,SAAS7B,QAAQ6B,OAAO;QACxBC,SAASxH,aAAa0F,QAAQG,IAAI,EAAEH,QAAQI,IAAI;QAChDK,IAAI,EAAEF,iBAAAA,SAAUP,QAAQS,IAAI;QAC5BC;QACAW,WAAWrB,QAAQqB,SAAS,IAAIrB,QAAQpC,EAAE;QAC1CmE,WAAW/B,QAAQ+B,SAAS;QAC5BC,MAAMhC,QAAQiC,YAAY;QAC1BC,KAAK,WAAET,0BAAAA,OAAQU,GAAG,oBAAI;QACtBC,MAAM,WAAEX,0BAAAA,OAAQY,IAAI,oBAAI;QACxBpD,MAAMmC,MAAMnC,IAAI;QAChBF,QAAQqC,MAAMrC,MAAM;QACpBU,WAAW2B,MAAM3B,SAAS;IAC5B;IACA,MAAM6C,UAAU,MAAMjH,uBAAuBiG,KAAKM;IAClD,OAAOU,YAAY,YACf;QAAEnB,SAAS;QAASK,YAAYF,IAAI1D,EAAE;QAAEwD;IAAM,IAC9C;QAAED,SAAS;QAAaK,YAAYF,IAAI1D,EAAE;IAAC;AACjD"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/crm-inbound-email.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 consentGroupForHost,\n contactCaptureHostIds,\n crmActivityLogHasRoom,\n type CrmActivityLink,\n type CrmEmailDirection,\n crmScopeTokens,\n emailAddressOf,\n emailDomainOf,\n emailExcerpt,\n findMemberByEmailAddress,\n forwardedSection,\n htmlToPlainText,\n personKey,\n type RosterMember,\n} from '@aglyn/aglyn/server'\nimport type { ReceivedEmail } from '@aglyn/shared-util-email'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { countCrmActivitiesForRecord } from '@aglyn/tenant-data-admin/server/crm-records'\nimport {\n buildCrmCapturedEmailActivity,\n crmCapturedEmailKey,\n type CrmInboundCandidate,\n crmInboundCandidates,\n isCrmInboundAddress,\n isCrmInboundToken,\n mintCrmInboundToken,\n} from '../model/crm-inbound'\nimport {\n createCrmEmailActivity,\n crmCapturedEmailActivityRef,\n newCrmActivityRef,\n} from './crm-email-activity'\nimport { crmActivityLinkWithCompany } from './crm-activity-company'\nimport { stampCrmActivityCompany } from '../model/crm-activity-link'\n\n/**\n * EMAIL CAPTURE (AGL-2657): the half with Firestore.\n *\n * The org's capture token — minted once, replaced on a rotation, found\n * again from a message's recipient — and the filing of one received\n * message: which record it was with, whether it has been filed before, and\n * the row it becomes. The pure rules are `@aglyn/aglyn`'s\n * `crm-inbound.ts`; this module is the reads and the one write.\n *\n * Firestore is a parameter throughout, as in `crm-records.ts` and\n * `crm-email-activity.ts`: the routes hold one, and a module that reached\n * for the Admin app itself would drag it into every spec that only wants\n * the matching. The roster is read through that same store by\n * `loadMemberAddressRoster`, and the caller writes the org's feed line, so a\n * spec of this module needs a store and nothing else.\n */\n\n/**\n * The workspace's email capture address, as the org document carries it\n * under `crmInbound` (AGL-2657): `crm+<token>@<capture domain>`.\n *\n * One token per organization, minted lazily by `crm/inbound-address` the\n * first time a member asks for the address, and replaced by the same route\n * on a rotation. Every site of the organization shares it: a captured\n * message is filed on the record that matches its correspondent, and the\n * record says which site it belongs to, so the address need not.\n *\n * The CRM's field, not the platform's: the organization type does not name\n * it, and this module is its only reader and writer. Server-owned and\n * member-readable: the token IS the address, so a client that could write\n * it could point another workspace's mail here.\n */\nexport interface OrgCrmInbound {\n /** The url-safe secret in the local part; `crm+<token>@…`. */\n token: string\n /** When the first token was minted, epoch ms. */\n createdAtMs: number\n /** When the token was last replaced, epoch ms; absent until it has been. */\n rotatedAtMs?: number\n}\n\n/** The most candidate addresses one message is matched by. */\nexport const CRM_INBOUND_CANDIDATE_MAX = 6\n\n/** The most sites whose leads are consulted for one message. */\nexport const CRM_INBOUND_LEAD_HOST_MAX = 50\n\nexport interface CrmInboundTokenResult {\n token: string\n createdAtMs: number\n rotatedAtMs?: number\n /** True when this call replaced a token that existed. */\n rotated: boolean\n}\n\n/**\n * The org's capture token, minted the first time anybody asks and kept\n * thereafter; with `rotate`, replaced. A transaction, so two members\n * opening the settings page at once mint one token and not two, and a\n * rotation lands on the token the reader saw.\n */\nexport async function ensureCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n options: { rotate?: boolean; nowMs?: number } = {},\n): Promise<CrmInboundTokenResult> {\n const rotate = options.rotate === true\n const nowMs = options.nowMs ?? Date.now()\n const orgRef = firestore.collection('orgs').doc(orgId)\n return firestore.runTransaction(async (tx) => {\n const snapshot = await tx.get(orgRef)\n if (!snapshot.exists) throw new Error(`[crm-inbound] unknown org ${orgId}`)\n const current = (snapshot.get('crmInbound') ?? null) as Partial<OrgCrmInbound> | null\n const held = isCrmInboundToken(current?.token)\n if (held && !rotate) {\n const createdAtMs = Number(current?.createdAtMs)\n return {\n token: String(current?.token),\n createdAtMs: Number.isFinite(createdAtMs) && createdAtMs > 0 ? createdAtMs : nowMs,\n ...(typeof current?.rotatedAtMs === 'number' ? { rotatedAtMs: current.rotatedAtMs } : {}),\n rotated: false,\n }\n }\n const createdAtMs =\n held && Number.isFinite(Number(current?.createdAtMs)) && Number(current?.createdAtMs) > 0\n ? Number(current?.createdAtMs)\n : nowMs\n const next: OrgCrmInbound = {\n token: mintCrmInboundToken(),\n createdAtMs,\n ...(held ? { rotatedAtMs: nowMs } : {}),\n }\n tx.update(orgRef, { crmInbound: next })\n return { ...next, rotated: held }\n })\n}\n\n/**\n * The org a capture token names, or `null`. One equality on\n * `crmInbound.token`, which Firestore indexes on its own; a token that is\n * not one by shape is refused before the query, so a malformed local part\n * costs no read.\n */\nexport async function findOrgByCrmInboundToken(\n firestore: FirebaseFirestore.Firestore,\n token: unknown,\n): Promise<{ orgId: string; org: Record<string, unknown> } | null> {\n if (!isCrmInboundToken(token)) return null\n const page = await firestore\n .collection('orgs')\n .where('crmInbound.token', '==', token)\n .limit(1)\n .get()\n const doc = page.docs[0]\n if (!doc) return null\n return { orgId: doc.id, org: { $id: doc.id, ...(doc.data() ?? {}) } }\n}\n\n/**\n * Every site of the organization, by id, up to the ceiling — the set a\n * message's lead lookup sweeps. One equality on `hosts.orgId`.\n */\nexport async function crmInboundHostIds(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n): Promise<string[]> {\n const page = await firestore\n .collection('hosts')\n .where('orgId', '==', orgId)\n .limit(CRM_INBOUND_LEAD_HOST_MAX)\n .get()\n return page.docs.map((doc) => String(doc.id))\n}\n\n/** The record one of a message's addresses resolved to. */\nexport interface CrmInboundMatch {\n email: string\n direction: CrmEmailDirection\n kind: 'contact' | 'lead'\n /** Where the row is filed. */\n link: CrmActivityLink\n /** The site the row is stamped with — the record's own. */\n hostId: string\n /** Who may list the row — the record's own tokens. */\n visibleTo: string[]\n}\n\n/**\n * The first candidate that is a contact of the org — through the address\n * index, so a merged record's alternate address answers — or a lead on any\n * of its sites. Candidates are tried in the order the rule gave them and\n * the first hit wins; a contact is looked for before a lead at each\n * address, because a converted lead's address is its contact's.\n */\nexport async function matchCrmInboundCorrespondent(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n candidates: readonly CrmInboundCandidate[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundMatch | null> {\n const { orgId, org } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contacts = orgRef.collection('contacts')\n const tokensOf = (record: Record<string, unknown>): string[] => {\n const raw = record['visibleTo']\n return Array.isArray(raw) ? raw.map((token) => String(token)) : []\n }\n for (const candidate of input.candidates.slice(0, CRM_INBOUND_CANDIDATE_MAX)) {\n const contact = await findContactByEmail(contacts, candidate.email)\n if (contact) {\n const record = (contact.data() ?? {}) as Record<string, unknown>\n const hostId =\n String(record['hostId'] ?? '').trim() || contactCaptureHostIds(record)[0] || ''\n // The company the capturing site files this person under, so the row\n // shows on the company's log as a sent email's does.\n const link: CrmActivityLink = stampCrmActivityCompany(\n { contactId: contact.id },\n { contact: record, groupId: hostId ? consentGroupForHost(org, hostId).groupId : null, org },\n )\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'contact',\n link,\n hostId,\n visibleTo: tokensOf(record),\n }\n }\n const key = personKey(candidate.email)\n if (!key) continue\n /*\n * ONE ROW, then which of the named sites may see it (AGL-3275).\n *\n * This used to read a lead per site because there was one per site. There\n * is one now, so the read is single and the LOOP is what it always really\n * was: deciding whose mailbox this reply belongs to. A site that cannot\n * see the row is not that site, which is what `visibleToHost` answers —\n * and skipping it is what keeps an agency's clients apart on a shared row.\n */\n const lead = await orgRef.collection('leads').doc(key).get()\n for (const hostId of input.hostIds.slice(0, CRM_INBOUND_LEAD_HOST_MAX)) {\n if (!lead.exists) break\n if (!visibleToHost(lead.get('visibleTo') as string[] | undefined, hostId)) continue\n // A converted lead's mail belongs on the contact it became as well, and\n // on that contact's company.\n const converted = String(lead.get('convertedContactId') ?? '').trim()\n const link = await crmActivityLinkWithCompany(\n orgRef,\n { leadId: lead.id, ...(converted ? { contactId: converted } : {}) } as CrmActivityLink,\n { groupId: consentGroupForHost(org, hostId).groupId, org },\n )\n return {\n email: candidate.email,\n direction: candidate.direction,\n kind: 'lead',\n link,\n hostId,\n // A lead carries no tokens of its own: the site's scope, as every\n // record created from that site is stamped.\n visibleTo: crmScopeTokens(org, consentGroupForHost(org, hostId)),\n }\n }\n }\n return null\n}\n\nexport type CrmInboundFileOutcome =\n /** A row was written. */\n | { outcome: 'filed'; activityId: string; match: CrmInboundMatch }\n /** The message had been filed before; nothing written. */\n | { outcome: 'duplicate'; activityId: string }\n /** No address on the message is a record of the org; nothing written. */\n | { outcome: 'unmatched'; senderDomain: string | null }\n /** The record's activity log is at its ceiling; nothing written. */\n | { outcome: 'ceiling'; match: CrmInboundMatch }\n\n/**\n * Files one received message on the record it was with (AGL-2657).\n *\n * The order is the cost: the candidate rule is arithmetic; the match is\n * the reads that decide everything after it; a message filed before is\n * answered off one read of the row it would write, before the ceiling's\n * aggregate; the ceiling comes last and refuses rather than dropping a row\n * on a full log. The write is a `create`, so two deliveries racing past\n * the read still yield one row. The BODY never leaves this function except\n * as the bounded excerpt in the row.\n */\nexport async function fileCrmInboundEmail(\n firestore: FirebaseFirestore.Firestore,\n input: {\n orgId: string\n org: Record<string, unknown>\n message: ReceivedEmail\n /** The capture domain, so the capture address is never the correspondent. */\n domain: string\n /** The roster, from `loadMemberAddressRoster`: sign-in addresses and confirmed aliases. */\n members: readonly RosterMember[]\n hostIds: readonly string[]\n },\n): Promise<CrmInboundFileOutcome> {\n const { orgId, org, message, domain } = input\n const plain = (message.text.trim() ? message.text : htmlToPlainText(message.html)).replace(\n /\\r\\n?/g,\n '\\n',\n )\n const forwarded = forwardedSection(plain)\n const { sender, senderIsMember, candidates } = crmInboundCandidates({\n from: message.from,\n to: [...message.to, ...message.receivedFor],\n cc: [...message.cc, ...message.bcc],\n forwardedFrom: forwarded?.from ?? undefined,\n domain,\n members: input.members,\n })\n const senderDomain = emailDomainOf(message.from)\n if (!candidates.length) return { outcome: 'unmatched', senderDomain }\n\n const match = await matchCrmInboundCorrespondent(firestore, {\n orgId,\n org,\n candidates,\n hostIds: input.hostIds,\n })\n if (!match) return { outcome: 'unmatched', senderDomain }\n\n const key = crmCapturedEmailKey(message.messageId, message.id)\n const ref = key\n ? crmCapturedEmailActivityRef(firestore, orgId, key)\n : newCrmActivityRef(firestore, orgId)\n if (key) {\n const existing = await ref.get()\n if (existing.exists) return { outcome: 'duplicate', activityId: ref.id }\n }\n\n const orgRef = firestore.collection('orgs').doc(orgId)\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, match.link))) {\n return { outcome: 'ceiling', match }\n }\n\n // The member the From is — by sign-in address, or by an alias exactly one\n // member confirmed. A shared alias names the team and no one person.\n const author = senderIsMember ? findMemberByEmailAddress(input.members, sender) : null\n // The address the message was written to, when it is worth showing: the\n // correspondent for a copied send, else the first recipient that is not\n // the capture address.\n const to =\n match.direction === 'outbound'\n ? match.email\n : (message.to\n .map((value) => emailAddressOf(value))\n .find((value) => value && !isCrmInboundAddress(value, domain)) ?? null)\n const activity = buildCrmCapturedEmailActivity({\n direction: match.direction,\n subject: message.subject,\n excerpt: emailExcerpt(message.text, message.html),\n from: sender ?? message.from,\n to,\n messageId: message.messageId || message.id,\n inReplyTo: message.inReplyTo,\n atMs: message.receivedAtMs,\n byUid: author?.uid ?? '',\n byName: author?.name ?? null,\n link: match.link,\n hostId: match.hostId,\n visibleTo: match.visibleTo,\n })\n const created = await createCrmEmailActivity(ref, activity)\n return created === 'created'\n ? { outcome: 'filed', activityId: ref.id, match }\n : { outcome: 'duplicate', activityId: ref.id }\n}\n"],"names":["consentGroupForHost","contactCaptureHostIds","crmActivityLogHasRoom","crmScopeTokens","emailAddressOf","emailDomainOf","emailExcerpt","findMemberByEmailAddress","forwardedSection","htmlToPlainText","personKey","visibleToHost","findContactByEmail","countCrmActivitiesForRecord","buildCrmCapturedEmailActivity","crmCapturedEmailKey","crmInboundCandidates","isCrmInboundAddress","isCrmInboundToken","mintCrmInboundToken","createCrmEmailActivity","crmCapturedEmailActivityRef","newCrmActivityRef","crmActivityLinkWithCompany","stampCrmActivityCompany","CRM_INBOUND_CANDIDATE_MAX","CRM_INBOUND_LEAD_HOST_MAX","ensureCrmInboundToken","firestore","orgId","options","rotate","nowMs","Date","now","orgRef","collection","doc","runTransaction","tx","snapshot","get","exists","Error","current","held","token","createdAtMs","Number","String","isFinite","rotatedAtMs","rotated","next","update","crmInbound","findOrgByCrmInboundToken","page","where","limit","docs","id","org","$id","data","crmInboundHostIds","map","matchCrmInboundCorrespondent","input","contacts","tokensOf","record","raw","Array","isArray","candidate","candidates","slice","contact","email","hostId","trim","link","contactId","groupId","direction","kind","visibleTo","key","lead","hostIds","converted","leadId","fileCrmInboundEmail","message","domain","plain","text","html","replace","forwarded","sender","senderIsMember","from","to","receivedFor","cc","bcc","forwardedFrom","undefined","members","senderDomain","length","outcome","match","messageId","ref","existing","activityId","author","value","find","activity","subject","excerpt","inReplyTo","atMs","receivedAtMs","byUid","uid","byName","name","created"],"mappings":"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"}