@aglyn/tenant-runtime 1.0.0-beta.152 → 1.0.0-beta.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -3
- package/src/lib/capture-host-contact.d.ts +6 -0
- package/src/lib/capture-host-contact.js +6 -0
- package/src/lib/capture-host-contact.js.map +1 -1
- package/src/lib/convert-host-lead.js +61 -13
- package/src/lib/convert-host-lead.js.map +1 -1
- package/src/lib/hand-off-lead.d.ts +34 -0
- package/src/lib/hand-off-lead.js +95 -0
- package/src/lib/hand-off-lead.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-runtime",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.153",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.153",
|
|
29
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.153",
|
|
30
30
|
"@swc/helpers": "0.5.23"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
@@ -69,6 +69,12 @@ import type { HostEventPayload } from './host-event-listeners';
|
|
|
69
69
|
* because the created-report carries the identity and not the routing.
|
|
70
70
|
* It never rejects either; a record it could not assign is one somebody
|
|
71
71
|
* assigns by hand, which is what every record was before this existed.
|
|
72
|
+
*
|
|
73
|
+
* What this does NOT do is close a lead (AGL-3232): a lead is the CRM's
|
|
74
|
+
* record, and which relationship closes one is the CRM's rule, answered in
|
|
75
|
+
* its capture writer for the doors that report through the capture seam.
|
|
76
|
+
* The order doors call this directly and close nothing — a buyer with an
|
|
77
|
+
* open lead is a lead the rep converts, as in Salesforce.
|
|
72
78
|
*/
|
|
73
79
|
export declare function captureHostContact(options: Omit<UpsertHostContactOptions, 'onCreated'>): Promise<UpsertHostContactVerdict>;
|
|
74
80
|
/**
|
|
@@ -71,6 +71,12 @@ import { emitHostEvent } from "./emit-host-event.js";
|
|
|
71
71
|
* because the created-report carries the identity and not the routing.
|
|
72
72
|
* It never rejects either; a record it could not assign is one somebody
|
|
73
73
|
* assigns by hand, which is what every record was before this existed.
|
|
74
|
+
*
|
|
75
|
+
* What this does NOT do is close a lead (AGL-3232): a lead is the CRM's
|
|
76
|
+
* record, and which relationship closes one is the CRM's rule, answered in
|
|
77
|
+
* its capture writer for the doors that report through the capture seam.
|
|
78
|
+
* The order doors call this directly and close nothing — a buyer with an
|
|
79
|
+
* open lead is a lead the rep converts, as in Salesforce.
|
|
74
80
|
*/ export async function captureHostContact(options) {
|
|
75
81
|
return upsertHostContact(_extends({}, options, {
|
|
76
82
|
onCreated: async (created)=>{
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/capture-host-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type HostContactCreated,\n upsertHostContact,\n type UpsertHostContactOptions,\n type UpsertHostContactVerdict,\n} from '@aglyn/tenant-data-admin'\nimport { assignOwnerForCapture } from './assign-contact-owner'\nimport { associateCompanyByDomain } from './associate-company-by-domain'\nimport { emitHostEvent } from './emit-host-event'\nimport type { HostEventPayload } from './host-event-listeners'\n\n/**\n * THE contact capture door for a server path (AGL-2605).\n *\n * `upsertHostContact` writes the contact and reports, through its\n * `onCreated` hook, when the write made a NEW person. This wrapper is the\n * one place that hook is bound to `contactCreated`, and it is the function\n * every server door calls — the forms route, the membership and newsletter\n * handlers, the order and booking webhooks. A door that calls\n * `upsertHostContact` directly still captures the contact and fires nothing,\n * which is a contact no automation can welcome; `capture-host-contact.spec`\n * scans for exactly that call.\n *\n * ## Why the binding lives here and not in the data library\n *\n * What listens for the event — the automation engine among them — imports\n * the data library for its Firestore handle, its org helpers and its\n * senders. The data library emitting an event would import the listeners\n * back, which is a cycle, and the module boundaries (`scope:data` depends on\n * data and util only) refuse it in any case. So the lower layer reports a\n * fact and this layer, which already knows how to announce one, announces\n * it. The alternative — a sink the runtime registers into when its module\n * happens to be imported — would fire only in a process that had loaded the\n * runtime, and a Stripe webhook that created a contact in a process that had\n * not would announce nothing with no error anywhere. The listeners themselves\n * are registered by a call made at boot (`host-event-listeners.ts`), so every\n * process has them before its first request.\n *\n * Fire-and-forget in the same sense the capture itself is: the hook's\n * failure is caught inside `upsertHostContact`, so a runner that throws\n * costs the door nothing, and this function never rejects.\n *\n * ## The company, before the announcement (AGL-2613)\n *\n * A new person with a work email address is linked to the company at that\n * domain here, in the same hook, BEFORE `contactCreated` goes out — so an\n * automation that reads the new contact finds them already filed, rather\n * than racing a link that lands a moment later. Only when the door did not\n * name a company itself: the console's drawer and an import row that\n * carried one have written it into the facet, and the capture must not\n * second-guess a person's choice with a domain match. The association never\n * rejects, and its own catch keeps a failed lookup from costing the event.\n *\n * ## The owner, on the same terms (AGL-2618)\n *\n * Then the org's assignment rules and the site's default owner decide who\n * follows the new person up — again before `contactCreated`, so an\n * automation's \"create a task for the owner\" finds one — and again only\n * when the door named none: a drawer, an import column or a conversion\n * that picked an owner has said whose the record is. The pass reads the\n * form and the tags off the same options the data library was handed,\n * because the created-report carries the identity and not the routing.\n * It never rejects either; a record it could not assign is one somebody\n * assigns by hand, which is what every record was before this existed.\n */\nexport async function captureHostContact(\n options: Omit<UpsertHostContactOptions, 'onCreated'>,\n): Promise<UpsertHostContactVerdict> {\n return upsertHostContact({\n ...options,\n onCreated: async (created) => {\n if (!options.facet?.companyId) {\n await associateCompanyByDomain(created).catch((error: unknown) => {\n console.error('captureHostContact company association failed', error)\n })\n }\n if (!options.facet?.ownerUid) {\n await assignOwnerForCapture({\n hostId: created.hostId,\n contactId: created.contactId,\n email: created.email,\n source: created.source,\n formId: options.interaction.formId ?? null,\n tags: options.tags,\n }).catch((error: unknown) => {\n console.error('captureHostContact owner assignment failed', error)\n })\n }\n await emitHostEvent(\n created.hostId,\n 'contactCreated',\n contactCreatedPayload(created, options.interaction.formId),\n )\n },\n })\n}\n\n/**\n * The `contactCreated` payload, as the scalars an event may carry.\n *\n * `HostEventPayload` holds strings, numbers and booleans and nothing\n * nested, because the payload seeds an expression scope and a condition\n * editor whose operators compare strings. So `campaignIds` rides as one\n * comma-joined string — `contains` still finds a campaign in it — and is\n * present only when the capture had campaigns, so `notEmpty` on it reads\n * as \"came in through a campaign form\". `name` and `lifecycleStage` are\n * always present, empty when the door had none, so a condition on either\n * never sees a missing key: `lifecycleStage == \"lead\"` is the filter that\n * picks the form captures out of the sign-ups (AGL-2612).\n *\n * `formId` rides the same way `campaignIds` does — present only when the\n * capture came through a form — because the created-report carries the\n * person's identity and not the routing, and the form is routing: it is\n * read off the interaction the door was handed. Present, it is what lets a\n * condition say `formId` equals this form and no other (AGL-2626); absent,\n * `notEmpty` on it reads as \"came in through a form\".\n */\nexport function contactCreatedPayload(\n created: HostContactCreated,\n formId?: string | null,\n): HostEventPayload {\n const form = String(formId ?? '').trim()\n return {\n contactId: created.contactId,\n email: created.email,\n name: created.name ?? '',\n source: created.source,\n hostId: created.hostId,\n lifecycleStage: created.lifecycleStage ?? '',\n ...(created.campaignIds.length\n ? { campaignIds: created.campaignIds.join(',') }\n : {}),\n ...(form ? { formId: form } : {}),\n }\n}\n\nexport default captureHostContact\n"],"names":["upsertHostContact","assignOwnerForCapture","associateCompanyByDomain","emitHostEvent","captureHostContact","options","onCreated","created","facet","companyId","catch","error","console","ownerUid","hostId","contactId","email","source","formId","interaction","tags","contactCreatedPayload","form","String","trim","name","lifecycleStage","campaignIds","length","join"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,iBAAiB,QAGZ,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,4BAAwB;AAC9D,SAASC,wBAAwB,QAAQ,mCAA+B;AACxE,SAASC,aAAa,QAAQ,uBAAmB;AAGjD
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/capture-host-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n type HostContactCreated,\n upsertHostContact,\n type UpsertHostContactOptions,\n type UpsertHostContactVerdict,\n} from '@aglyn/tenant-data-admin'\nimport { assignOwnerForCapture } from './assign-contact-owner'\nimport { associateCompanyByDomain } from './associate-company-by-domain'\nimport { emitHostEvent } from './emit-host-event'\nimport type { HostEventPayload } from './host-event-listeners'\n\n/**\n * THE contact capture door for a server path (AGL-2605).\n *\n * `upsertHostContact` writes the contact and reports, through its\n * `onCreated` hook, when the write made a NEW person. This wrapper is the\n * one place that hook is bound to `contactCreated`, and it is the function\n * every server door calls — the forms route, the membership and newsletter\n * handlers, the order and booking webhooks. A door that calls\n * `upsertHostContact` directly still captures the contact and fires nothing,\n * which is a contact no automation can welcome; `capture-host-contact.spec`\n * scans for exactly that call.\n *\n * ## Why the binding lives here and not in the data library\n *\n * What listens for the event — the automation engine among them — imports\n * the data library for its Firestore handle, its org helpers and its\n * senders. The data library emitting an event would import the listeners\n * back, which is a cycle, and the module boundaries (`scope:data` depends on\n * data and util only) refuse it in any case. So the lower layer reports a\n * fact and this layer, which already knows how to announce one, announces\n * it. The alternative — a sink the runtime registers into when its module\n * happens to be imported — would fire only in a process that had loaded the\n * runtime, and a Stripe webhook that created a contact in a process that had\n * not would announce nothing with no error anywhere. The listeners themselves\n * are registered by a call made at boot (`host-event-listeners.ts`), so every\n * process has them before its first request.\n *\n * Fire-and-forget in the same sense the capture itself is: the hook's\n * failure is caught inside `upsertHostContact`, so a runner that throws\n * costs the door nothing, and this function never rejects.\n *\n * ## The company, before the announcement (AGL-2613)\n *\n * A new person with a work email address is linked to the company at that\n * domain here, in the same hook, BEFORE `contactCreated` goes out — so an\n * automation that reads the new contact finds them already filed, rather\n * than racing a link that lands a moment later. Only when the door did not\n * name a company itself: the console's drawer and an import row that\n * carried one have written it into the facet, and the capture must not\n * second-guess a person's choice with a domain match. The association never\n * rejects, and its own catch keeps a failed lookup from costing the event.\n *\n * ## The owner, on the same terms (AGL-2618)\n *\n * Then the org's assignment rules and the site's default owner decide who\n * follows the new person up — again before `contactCreated`, so an\n * automation's \"create a task for the owner\" finds one — and again only\n * when the door named none: a drawer, an import column or a conversion\n * that picked an owner has said whose the record is. The pass reads the\n * form and the tags off the same options the data library was handed,\n * because the created-report carries the identity and not the routing.\n * It never rejects either; a record it could not assign is one somebody\n * assigns by hand, which is what every record was before this existed.\n *\n * What this does NOT do is close a lead (AGL-3232): a lead is the CRM's\n * record, and which relationship closes one is the CRM's rule, answered in\n * its capture writer for the doors that report through the capture seam.\n * The order doors call this directly and close nothing — a buyer with an\n * open lead is a lead the rep converts, as in Salesforce.\n */\nexport async function captureHostContact(\n options: Omit<UpsertHostContactOptions, 'onCreated'>,\n): Promise<UpsertHostContactVerdict> {\n return upsertHostContact({\n ...options,\n onCreated: async (created) => {\n if (!options.facet?.companyId) {\n await associateCompanyByDomain(created).catch((error: unknown) => {\n console.error('captureHostContact company association failed', error)\n })\n }\n if (!options.facet?.ownerUid) {\n await assignOwnerForCapture({\n hostId: created.hostId,\n contactId: created.contactId,\n email: created.email,\n source: created.source,\n formId: options.interaction.formId ?? null,\n tags: options.tags,\n }).catch((error: unknown) => {\n console.error('captureHostContact owner assignment failed', error)\n })\n }\n await emitHostEvent(\n created.hostId,\n 'contactCreated',\n contactCreatedPayload(created, options.interaction.formId),\n )\n },\n })\n}\n\n/**\n * The `contactCreated` payload, as the scalars an event may carry.\n *\n * `HostEventPayload` holds strings, numbers and booleans and nothing\n * nested, because the payload seeds an expression scope and a condition\n * editor whose operators compare strings. So `campaignIds` rides as one\n * comma-joined string — `contains` still finds a campaign in it — and is\n * present only when the capture had campaigns, so `notEmpty` on it reads\n * as \"came in through a campaign form\". `name` and `lifecycleStage` are\n * always present, empty when the door had none, so a condition on either\n * never sees a missing key: `lifecycleStage == \"lead\"` is the filter that\n * picks the form captures out of the sign-ups (AGL-2612).\n *\n * `formId` rides the same way `campaignIds` does — present only when the\n * capture came through a form — because the created-report carries the\n * person's identity and not the routing, and the form is routing: it is\n * read off the interaction the door was handed. Present, it is what lets a\n * condition say `formId` equals this form and no other (AGL-2626); absent,\n * `notEmpty` on it reads as \"came in through a form\".\n */\nexport function contactCreatedPayload(\n created: HostContactCreated,\n formId?: string | null,\n): HostEventPayload {\n const form = String(formId ?? '').trim()\n return {\n contactId: created.contactId,\n email: created.email,\n name: created.name ?? '',\n source: created.source,\n hostId: created.hostId,\n lifecycleStage: created.lifecycleStage ?? '',\n ...(created.campaignIds.length\n ? { campaignIds: created.campaignIds.join(',') }\n : {}),\n ...(form ? { formId: form } : {}),\n }\n}\n\nexport default captureHostContact\n"],"names":["upsertHostContact","assignOwnerForCapture","associateCompanyByDomain","emitHostEvent","captureHostContact","options","onCreated","created","facet","companyId","catch","error","console","ownerUid","hostId","contactId","email","source","formId","interaction","tags","contactCreatedPayload","form","String","trim","name","lifecycleStage","campaignIds","length","join"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAEEA,iBAAiB,QAGZ,2BAA0B;AACjC,SAASC,qBAAqB,QAAQ,4BAAwB;AAC9D,SAASC,wBAAwB,QAAQ,mCAA+B;AACxE,SAASC,aAAa,QAAQ,uBAAmB;AAGjD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GACD,OAAO,eAAeC,mBACpBC,OAAoD;IAEpD,OAAOL,kBAAkB,aACpBK;QACHC,WAAW,OAAOC;gBACXF,gBAKAA;YALL,IAAI,GAACA,iBAAAA,QAAQG,KAAK,qBAAbH,eAAeI,SAAS,GAAE;gBAC7B,MAAMP,yBAAyBK,SAASG,KAAK,CAAC,CAACC;oBAC7CC,QAAQD,KAAK,CAAC,iDAAiDA;gBACjE;YACF;YACA,IAAI,GAACN,kBAAAA,QAAQG,KAAK,qBAAbH,gBAAeQ,QAAQ,GAAE;oBAMlBR;gBALV,MAAMJ,sBAAsB;oBAC1Ba,QAAQP,QAAQO,MAAM;oBACtBC,WAAWR,QAAQQ,SAAS;oBAC5BC,OAAOT,QAAQS,KAAK;oBACpBC,QAAQV,QAAQU,MAAM;oBACtBC,MAAM,GAAEb,8BAAAA,QAAQc,WAAW,CAACD,MAAM,YAA1Bb,8BAA8B;oBACtCe,MAAMf,QAAQe,IAAI;gBACpB,GAAGV,KAAK,CAAC,CAACC;oBACRC,QAAQD,KAAK,CAAC,8CAA8CA;gBAC9D;YACF;YACA,MAAMR,cACJI,QAAQO,MAAM,EACd,kBACAO,sBAAsBd,SAASF,QAAQc,WAAW,CAACD,MAAM;QAE7D;;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASG,sBACdd,OAA2B,EAC3BW,MAAsB;QAMdX,eAGUA;IAPlB,MAAMe,OAAOC,OAAOL,iBAAAA,SAAU,IAAIM,IAAI;IACtC,OAAO;QACLT,WAAWR,QAAQQ,SAAS;QAC5BC,OAAOT,QAAQS,KAAK;QACpBS,IAAI,GAAElB,gBAAAA,QAAQkB,IAAI,YAAZlB,gBAAgB;QACtBU,QAAQV,QAAQU,MAAM;QACtBH,QAAQP,QAAQO,MAAM;QACtBY,cAAc,GAAEnB,0BAAAA,QAAQmB,cAAc,YAAtBnB,0BAA0B;OACtCA,QAAQoB,WAAW,CAACC,MAAM,GAC1B;QAAED,aAAapB,QAAQoB,WAAW,CAACE,IAAI,CAAC;IAAK,IAC7C,CAAC,GACDP,OAAO;QAAEJ,QAAQI;IAAK,IAAI,CAAC;AAEnC;AAEA,eAAelB,mBAAkB"}
|
|
@@ -73,11 +73,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
73
73
|
* nobody chose an owner for — and no rule assigned — stays unowned, which
|
|
74
74
|
* the console renders honestly as unassigned. Either way the caller has
|
|
75
75
|
* already been authorized by its door; nothing here asks again.
|
|
76
|
-
*/ import { consentGroupScope, CRM_COLLECTIONS, crmScopeTokens, DEFAULT_DEAL_STAGES, nameSearchFields, contactFacetPath, normalizeContactEmail, ORG_SCOPE_TOKEN, readContactFacet } from "@aglyn/aglyn/server";
|
|
76
|
+
*/ import { consentGroupScope, CRM_COLLECTIONS, crmScopeTokens, DEFAULT_DEAL_STAGES, nameSearchFields, contactFacetPath, normalizeContactEmail, ORG_SCOPE_TOKEN, readContactFacet, readMarketingBasis, soloConsentGroup } from "@aglyn/aglyn/server";
|
|
77
77
|
import { consentGroupForSite, crmRecordsQuotaForOrg, findContactByEmail, logHostActivity, orgDataCollectionForHost, writeContactCompanyLink } from "@aglyn/tenant-data-admin";
|
|
78
78
|
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
79
79
|
import { assignOwnerForCapture, notifyRecordAssigned } from "./assign-contact-owner.js";
|
|
80
80
|
import { captureHostContact } from "./capture-host-contact.js";
|
|
81
|
+
import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
81
82
|
/**
|
|
82
83
|
* The stage a new deal opens in.
|
|
83
84
|
*
|
|
@@ -161,7 +162,18 @@ import { captureHostContact } from "./capture-host-contact.js";
|
|
|
161
162
|
* contact's timeline can be walked back to what was captured. The facet
|
|
162
163
|
* carries the stage and the owner, which is what makes this a sales
|
|
163
164
|
* record rather than another form capture.
|
|
164
|
-
|
|
165
|
+
*
|
|
166
|
+
* THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce
|
|
167
|
+
* hands a lead's fields to the contact: phone, title, address, the
|
|
168
|
+
* company as text (until step 2 links a record, whose own name then
|
|
169
|
+
* replaces it), tags, and the marketing basis the lead recorded — the
|
|
170
|
+
* person ticked the box on the lead, and a conversion must not be the
|
|
171
|
+
* moment they lose it. Only what the lead holds is handed over; a value
|
|
172
|
+
* the contact already carries is the contact's, because the capture door
|
|
173
|
+
* writes a profile field only where the facet has none.
|
|
174
|
+
*=========================================*/ const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : [];
|
|
175
|
+
const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId));
|
|
176
|
+
const captured = await captureHostContact(_extends({
|
|
165
177
|
hostId,
|
|
166
178
|
email
|
|
167
179
|
}, leadName ? {
|
|
@@ -171,11 +183,24 @@ import { captureHostContact } from "./capture-host-contact.js";
|
|
|
171
183
|
interaction: {
|
|
172
184
|
summary: 'Converted from a lead',
|
|
173
185
|
refId: leadId
|
|
174
|
-
}
|
|
186
|
+
}
|
|
187
|
+
}, leadConsent.basis === 'granted' ? {
|
|
188
|
+
marketingConsent: true
|
|
189
|
+
} : {}, leadTags.length ? {
|
|
190
|
+
tags: leadTags
|
|
191
|
+
} : {}, {
|
|
175
192
|
facet: _extends({
|
|
176
193
|
lifecycleStage: 'sales-qualified'
|
|
177
194
|
}, chosenOwner ? {
|
|
178
195
|
ownerUid: chosenOwner
|
|
196
|
+
} : {}, typeof lead.phone === 'string' && lead.phone ? {
|
|
197
|
+
phone: lead.phone
|
|
198
|
+
} : {}, typeof lead.jobTitle === 'string' && lead.jobTitle ? {
|
|
199
|
+
jobTitle: lead.jobTitle
|
|
200
|
+
} : {}, lead.address && typeof lead.address === 'object' ? {
|
|
201
|
+
address: lead.address
|
|
202
|
+
} : {}, typeof lead.company === 'string' && lead.company ? {
|
|
203
|
+
companyName: lead.company
|
|
179
204
|
} : {})
|
|
180
205
|
}));
|
|
181
206
|
/*
|
|
@@ -270,18 +295,26 @@ import { captureHostContact } from "./capture-host-contact.js";
|
|
|
270
295
|
};
|
|
271
296
|
companyId = requestedCompanyId;
|
|
272
297
|
} else if (createCompany) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
298
|
+
/*
|
|
299
|
+
* FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its
|
|
300
|
+
* company as text, and a company the org already files under that
|
|
301
|
+
* name is the account, whatever domain it carries. Then the domain,
|
|
302
|
+
* the key two contacts at one business share — a second company
|
|
303
|
+
* document for `acme.com` would split the account the key exists to
|
|
304
|
+
* join. Only a company the caller can see counts as found — see
|
|
305
|
+
* `readableTokens`.
|
|
306
|
+
*/ const visibleAmong = (snapshots)=>{
|
|
307
|
+
var _snapshots_find;
|
|
308
|
+
return (_snapshots_find = snapshots.find((snapshot)=>{
|
|
281
309
|
const tokens = snapshot.get('visibleTo');
|
|
282
310
|
return Array.isArray(tokens) && tokens.some((token)=>readableTokens.has(String(token)));
|
|
283
|
-
});
|
|
284
|
-
|
|
311
|
+
})) == null ? void 0 : _snapshots_find.id;
|
|
312
|
+
};
|
|
313
|
+
const byName = await orgRef.collection(CRM_COLLECTIONS.companies).where('nameLower', '==', createCompany.name.toLowerCase()).limit(5).get();
|
|
314
|
+
companyId = visibleAmong(byName.docs);
|
|
315
|
+
if (!companyId && createCompany.domain) {
|
|
316
|
+
const byDomain = await orgRef.collection(CRM_COLLECTIONS.companies).where('domain', '==', createCompany.domain).limit(5).get();
|
|
317
|
+
companyId = visibleAmong(byDomain.docs);
|
|
285
318
|
}
|
|
286
319
|
if (!companyId) {
|
|
287
320
|
if (await bandFull()) return {
|
|
@@ -413,6 +446,21 @@ import { captureHostContact } from "./capture-host-contact.js";
|
|
|
413
446
|
updatedAt: FieldValue.serverTimestamp()
|
|
414
447
|
}));
|
|
415
448
|
/*
|
|
449
|
+
* WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the
|
|
450
|
+
* contact, and every plugin with a listener — Sequences, for an
|
|
451
|
+
* enrollment naming the lead — re-points its own records. After the
|
|
452
|
+
* stamp, so a listener reading the lead finds the conversion on it; it
|
|
453
|
+
* never throws.
|
|
454
|
+
*/ await handOffLeadRecords({
|
|
455
|
+
firestore,
|
|
456
|
+
orgId,
|
|
457
|
+
hostId,
|
|
458
|
+
leadId,
|
|
459
|
+
contactId,
|
|
460
|
+
email,
|
|
461
|
+
by: actor.kind === 'member' ? 'member' : 'api'
|
|
462
|
+
});
|
|
463
|
+
/*
|
|
416
464
|
* The audit line, from the function that did the work (AGL-2622): the
|
|
417
465
|
* conversion is one act however many records it opened, so it is one
|
|
418
466
|
* entry, on the lead, in the feed of the site that holds the lead. A
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/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\nimport {\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\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'\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 } | 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' | '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/** 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 const leadRef = firestore\n .collection('hosts')\n .doc(hostId)\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 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 facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\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 } 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 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 } else if (createCompany) {\n if (createCompany.domain) {\n /*\n * FIND BEFORE CREATE. The domain is the key two contacts at one\n * business share, and a second company document for `acme.com`\n * would split the account the key exists to join. Only a company the\n * caller can see counts as found — see `readableTokens`.\n */\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n const visible = byDomain.docs.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 if (visible) companyId = visible.id\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\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 companyId = created.id\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 created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\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 ...(ownerUid ? { ownerUid } : {}),\n contactId,\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\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 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 * 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":["consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","contactSnapshot","actor","lead","firestore","hostId","orgId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","trim","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","Set","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","captured","name","source","interaction","summary","refId","facet","lifecycleStage","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","domain","byDomain","where","limit","visible","docs","snapshot","tokens","Array","isArray","some","token","has","created","add","createdByUid","createdAt","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","deals","title","titleLower","toLowerCase","status","amountCents","currency","stageChangedAtMs","stamp","convertedAtMs","apiKeyName","type"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/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\nimport {\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\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 { handOffLeadRecords } from './hand-off-lead'\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 } | 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' | '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/** 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 const leadRef = firestore\n .collection('hosts')\n .doc(hostId)\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, 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 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 ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\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 ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\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 } 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 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 } 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 ): string | 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 })?.id\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n companyId = visibleAmong(byName.docs)\n if (!companyId && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n companyId = visibleAmong(byDomain.docs)\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\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 companyId = created.id\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 created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\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 ...(ownerUid ? { ownerUid } : {}),\n contactId,\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\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 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":["consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","handOffLeadRecords","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","contactSnapshot","actor","lead","firestore","hostId","orgId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","trim","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","Set","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","leadTags","Array","isArray","tags","map","leadConsent","captured","name","source","interaction","summary","refId","basis","marketingConsent","facet","lifecycleStage","phone","jobTitle","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","created","add","createdByUid","createdAt","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","deals","title","titleLower","status","amountCents","currency","stageChangedAtMs","stamp","convertedAtMs","by","apiKeyName","type"],"mappings":"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"}
|
|
@@ -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
|
+
*/
|
|
17
|
+
import { type PluginLeadConversionBy } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
|
|
18
|
+
export interface HandOffLeadRecordsInput {
|
|
19
|
+
firestore: FirebaseFirestore.Firestore;
|
|
20
|
+
orgId: string;
|
|
21
|
+
hostId: string;
|
|
22
|
+
leadId: string;
|
|
23
|
+
contactId: string;
|
|
24
|
+
email: unknown;
|
|
25
|
+
by: PluginLeadConversionBy;
|
|
26
|
+
}
|
|
27
|
+
export interface HandOffLeadRecordsReport {
|
|
28
|
+
activities: number;
|
|
29
|
+
tasks: number;
|
|
30
|
+
/** Each plugin's own report, or `null` for a listener that threw. */
|
|
31
|
+
plugins: Record<string, Readonly<Record<string, number | boolean | null>> | null>;
|
|
32
|
+
}
|
|
33
|
+
export declare function handOffLeadRecords(input: HandOffLeadRecordsInput): Promise<HandOffLeadRecordsReport>;
|
|
34
|
+
export default handOffLeadRecords;
|
|
@@ -0,0 +1,95 @@
|
|
|
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
|
+
* WHAT A LEAD HANDS TO THE CONTACT IT BECAME (AGL-3233).
|
|
18
|
+
*
|
|
19
|
+
* Salesforce's convert moves the lead's activities to the contact, so the
|
|
20
|
+
* calls and emails a rep logged while working the lead are the first
|
|
21
|
+
* entries on the contact's timeline rather than history stranded on a
|
|
22
|
+
* frozen record. This is that move, for every door that converts a lead —
|
|
23
|
+
* the dialog, the REST API, and the doors that close a lead on their own
|
|
24
|
+
* when the person signs up or buys — so the two records never disagree
|
|
25
|
+
* about what happened.
|
|
26
|
+
*
|
|
27
|
+
* Three things move, once the lead carries `convertedContactId`:
|
|
28
|
+
*
|
|
29
|
+
* - every activity filed on the lead gains the contact, and keeps the
|
|
30
|
+
* lead: the contact's timeline reads it, and the lead's page still does;
|
|
31
|
+
* - every task filed on the lead gains the contact the same way;
|
|
32
|
+
* - every plugin with a lead-conversion listener is told — a sequence
|
|
33
|
+
* enrollment naming the lead re-points itself to the contact.
|
|
34
|
+
*
|
|
35
|
+
* Batched, bounded and never throwing: the conversion has happened, and a
|
|
36
|
+
* timeline that could not be moved is one somebody reads on the lead's
|
|
37
|
+
* page, which still links to the contact. The counts are returned for the
|
|
38
|
+
* caller's own report.
|
|
39
|
+
*/ import { CRM_COLLECTIONS, normalizeContactEmail } from "@aglyn/aglyn/server";
|
|
40
|
+
import { runPluginLeadConversionListeners } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
|
|
41
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
42
|
+
/** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
|
|
43
|
+
/** The most rows one hand-off moves — a lead with more has been worked for years. */ const ROWS_LIMIT = 2000;
|
|
44
|
+
/** Stamp `contactId` onto every row of `collection` that names the lead and not the contact yet. */ async function pointAtContact(firestore, collection, leadId, contactId) {
|
|
45
|
+
const rows = await collection.where('leadId', '==', leadId).limit(ROWS_LIMIT).get();
|
|
46
|
+
const refs = rows.docs.filter((row)=>{
|
|
47
|
+
var _row_get;
|
|
48
|
+
return String((_row_get = row.get('contactId')) != null ? _row_get : '') !== contactId;
|
|
49
|
+
}).map((row)=>row.ref);
|
|
50
|
+
for(let start = 0; start < refs.length; start += BATCH_LIMIT){
|
|
51
|
+
const batch = firestore.batch();
|
|
52
|
+
for (const ref of refs.slice(start, start + BATCH_LIMIT)){
|
|
53
|
+
batch.update(ref, {
|
|
54
|
+
contactId,
|
|
55
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
await batch.commit();
|
|
59
|
+
}
|
|
60
|
+
return refs.length;
|
|
61
|
+
}
|
|
62
|
+
export async function handOffLeadRecords(input) {
|
|
63
|
+
const { firestore, orgId, hostId, leadId, contactId, by } = input;
|
|
64
|
+
const orgRef = firestore.collection('orgs').doc(orgId);
|
|
65
|
+
const report = {
|
|
66
|
+
activities: 0,
|
|
67
|
+
tasks: 0,
|
|
68
|
+
plugins: {}
|
|
69
|
+
};
|
|
70
|
+
try {
|
|
71
|
+
report.activities = await pointAtContact(firestore, orgRef.collection(CRM_COLLECTIONS.activities), leadId, contactId);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error('handOffLeadRecords: activities', hostId, leadId, error);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
report.tasks = await pointAtContact(firestore, orgRef.collection(CRM_COLLECTIONS.tasks), leadId, contactId);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
console.error('handOffLeadRecords: tasks', hostId, leadId, error);
|
|
79
|
+
}
|
|
80
|
+
const email = normalizeContactEmail(input.email);
|
|
81
|
+
if (email) {
|
|
82
|
+
report.plugins = await runPluginLeadConversionListeners({
|
|
83
|
+
orgId,
|
|
84
|
+
hostId,
|
|
85
|
+
leadId,
|
|
86
|
+
contactId,
|
|
87
|
+
email,
|
|
88
|
+
by
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return report;
|
|
92
|
+
}
|
|
93
|
+
export default handOffLeadRecords;
|
|
94
|
+
|
|
95
|
+
//# sourceMappingURL=hand-off-lead.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/hand-off-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 * WHAT A LEAD HANDS TO THE CONTACT IT BECAME (AGL-3233).\n *\n * Salesforce's convert moves the lead's activities to the contact, so the\n * calls and emails a rep logged while working the lead are the first\n * entries on the contact's timeline rather than history stranded on a\n * frozen record. This is that move, for every door that converts a lead —\n * the dialog, the REST API, and the doors that close a lead on their own\n * when the person signs up or buys — so the two records never disagree\n * about what happened.\n *\n * Three things move, once the lead carries `convertedContactId`:\n *\n * - every activity filed on the lead gains the contact, and keeps the\n * lead: the contact's timeline reads it, and the lead's page still does;\n * - every task filed on the lead gains the contact the same way;\n * - every plugin with a lead-conversion listener is told — a sequence\n * enrollment naming the lead re-points itself to the contact.\n *\n * Batched, bounded and never throwing: the conversion has happened, and a\n * timeline that could not be moved is one somebody reads on the lead's\n * page, which still links to the contact. The counts are returned for the\n * caller's own report.\n */\n\nimport { CRM_COLLECTIONS, normalizeContactEmail } from '@aglyn/aglyn/server'\nimport {\n type PluginLeadConversionBy,\n runPluginLeadConversionListeners,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** The most rows one hand-off moves — a lead with more has been worked for years. */\nconst ROWS_LIMIT = 2_000\n\nexport interface HandOffLeadRecordsInput {\n firestore: FirebaseFirestore.Firestore\n orgId: string\n hostId: string\n leadId: string\n contactId: string\n email: unknown\n by: PluginLeadConversionBy\n}\n\nexport interface HandOffLeadRecordsReport {\n activities: number\n tasks: number\n /** Each plugin's own report, or `null` for a listener that threw. */\n plugins: Record<string, Readonly<Record<string, number | boolean | null>> | null>\n}\n\n/** Stamp `contactId` onto every row of `collection` that names the lead and not the contact yet. */\nasync function pointAtContact(\n firestore: FirebaseFirestore.Firestore,\n collection: FirebaseFirestore.CollectionReference,\n leadId: string,\n contactId: string,\n): Promise<number> {\n const rows = await collection.where('leadId', '==', leadId).limit(ROWS_LIMIT).get()\n const refs = rows.docs\n .filter((row) => String(row.get('contactId') ?? '') !== contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { contactId, updatedAt: FieldValue.serverTimestamp() })\n }\n await batch.commit()\n }\n return refs.length\n}\n\nexport async function handOffLeadRecords(\n input: HandOffLeadRecordsInput,\n): Promise<HandOffLeadRecordsReport> {\n const { firestore, orgId, hostId, leadId, contactId, by } = input\n const orgRef = firestore.collection('orgs').doc(orgId)\n const report: HandOffLeadRecordsReport = { activities: 0, tasks: 0, plugins: {} }\n try {\n report.activities = await pointAtContact(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.activities),\n leadId,\n contactId,\n )\n } catch (error) {\n console.error('handOffLeadRecords: activities', hostId, leadId, error)\n }\n try {\n report.tasks = await pointAtContact(\n firestore,\n orgRef.collection(CRM_COLLECTIONS.tasks),\n leadId,\n contactId,\n )\n } catch (error) {\n console.error('handOffLeadRecords: tasks', hostId, leadId, error)\n }\n const email = normalizeContactEmail(input.email)\n if (email) {\n report.plugins = await runPluginLeadConversionListeners({\n orgId,\n hostId,\n leadId,\n contactId,\n email,\n by,\n })\n }\n return report\n}\n\nexport default handOffLeadRecords\n"],"names":["CRM_COLLECTIONS","normalizeContactEmail","runPluginLeadConversionListeners","FieldValue","BATCH_LIMIT","ROWS_LIMIT","pointAtContact","firestore","collection","leadId","contactId","rows","where","limit","get","refs","docs","filter","row","String","map","ref","start","length","batch","slice","update","updatedAt","serverTimestamp","commit","handOffLeadRecords","input","orgId","hostId","by","orgRef","doc","report","activities","tasks","plugins","error","console","email"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;CAuBC,GAED,SAASA,eAAe,EAAEC,qBAAqB,QAAQ,sBAAqB;AAC5E,SAEEC,gCAAgC,QAC3B,qDAAoD;AAC3D,SAASC,UAAU,QAAQ,2BAA0B;AAErD,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,mFAAmF,GACnF,MAAMC,aAAa;AAmBnB,kGAAkG,GAClG,eAAeC,eACbC,SAAsC,EACtCC,UAAiD,EACjDC,MAAc,EACdC,SAAiB;IAEjB,MAAMC,OAAO,MAAMH,WAAWI,KAAK,CAAC,UAAU,MAAMH,QAAQI,KAAK,CAACR,YAAYS,GAAG;IACjF,MAAMC,OAAOJ,KAAKK,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAeA;eAAPC,QAAOD,WAAAA,IAAIJ,GAAG,CAAC,wBAARI,WAAwB,QAAQR;OACvDU,GAAG,CAAC,CAACF,MAAQA,IAAIG,GAAG;IACvB,IAAK,IAAIC,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAASlB,YAAa;QAC7D,MAAMoB,QAAQjB,UAAUiB,KAAK;QAC7B,KAAK,MAAMH,OAAON,KAAKU,KAAK,CAACH,OAAOA,QAAQlB,aAAc;YACxDoB,MAAME,MAAM,CAACL,KAAK;gBAAEX;gBAAWiB,WAAWxB,WAAWyB,eAAe;YAAG;QACzE;QACA,MAAMJ,MAAMK,MAAM;IACpB;IACA,OAAOd,KAAKQ,MAAM;AACpB;AAEA,OAAO,eAAeO,mBACpBC,KAA8B;IAE9B,MAAM,EAAExB,SAAS,EAAEyB,KAAK,EAAEC,MAAM,EAAExB,MAAM,EAAEC,SAAS,EAAEwB,EAAE,EAAE,GAAGH;IAC5D,MAAMI,SAAS5B,UAAUC,UAAU,CAAC,QAAQ4B,GAAG,CAACJ;IAChD,MAAMK,SAAmC;QAAEC,YAAY;QAAGC,OAAO;QAAGC,SAAS,CAAC;IAAE;IAChF,IAAI;QACFH,OAAOC,UAAU,GAAG,MAAMhC,eACxBC,WACA4B,OAAO3B,UAAU,CAACR,gBAAgBsC,UAAU,GAC5C7B,QACAC;IAEJ,EAAE,OAAO+B,OAAO;QACdC,QAAQD,KAAK,CAAC,kCAAkCR,QAAQxB,QAAQgC;IAClE;IACA,IAAI;QACFJ,OAAOE,KAAK,GAAG,MAAMjC,eACnBC,WACA4B,OAAO3B,UAAU,CAACR,gBAAgBuC,KAAK,GACvC9B,QACAC;IAEJ,EAAE,OAAO+B,OAAO;QACdC,QAAQD,KAAK,CAAC,6BAA6BR,QAAQxB,QAAQgC;IAC7D;IACA,MAAME,QAAQ1C,sBAAsB8B,MAAMY,KAAK;IAC/C,IAAIA,OAAO;QACTN,OAAOG,OAAO,GAAG,MAAMtC,iCAAiC;YACtD8B;YACAC;YACAxB;YACAC;YACAiC;YACAT;QACF;IACF;IACA,OAAOG;AACT;AAEA,eAAeP,mBAAkB"}
|