@aglyn/tenant-runtime 1.0.0-beta.204 → 1.0.0-beta.206
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/package.json +3 -3
- package/src/lib/capture-actor.d.ts +25 -0
- package/src/lib/capture-actor.js +40 -0
- package/src/lib/capture-actor.js.map +1 -0
- package/src/lib/capture-host-contact.d.ts +5 -2
- package/src/lib/capture-host-contact.js +10 -2
- package/src/lib/capture-host-contact.js.map +1 -1
- package/src/lib/convert-host-lead.js +11 -2
- package/src/lib/convert-host-lead.js.map +1 -1
- package/src/lib/emit-host-event.d.ts +5 -2
- package/src/lib/emit-host-event.js +5 -2
- package/src/lib/emit-host-event.js.map +1 -1
- package/src/lib/host-event-listeners.d.ts +28 -4
- package/src/lib/host-event-listeners.js +4 -4
- package/src/lib/host-event-listeners.js.map +1 -1
package/README.md
CHANGED
|
@@ -64,12 +64,22 @@ Listening to host events from a plugin's server code:
|
|
|
64
64
|
import { registerHostEventListener } from '@aglyn/tenant-runtime'
|
|
65
65
|
|
|
66
66
|
registerHostEventListener('my-plugin', {
|
|
67
|
-
async onEvent(hostId, event, payload) {
|
|
68
|
-
// react to the event; optionally return site alerts
|
|
67
|
+
async onEvent(hostId, event, payload, context) {
|
|
68
|
+
// react to the event; optionally return site alerts.
|
|
69
|
+
// `context.actor` is who caused it, when the emitting door knew:
|
|
70
|
+
// { kind: 'member' | 'visitor' | 'apiKey' | 'platform', uid?, email?, apiKeyName? }
|
|
69
71
|
},
|
|
70
72
|
})
|
|
71
73
|
```
|
|
72
74
|
|
|
75
|
+
Raising one, with who caused it:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
await emitHostEvent(hostId, 'contactStageChanged', payload, {
|
|
79
|
+
actor: { kind: 'member', uid, email },
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
73
83
|
Loading a screen on the server:
|
|
74
84
|
|
|
75
85
|
```ts
|
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.206",
|
|
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.206",
|
|
29
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.206",
|
|
30
30
|
"@swc/helpers": "0.5.23"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
@@ -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 { HostEventActor } from './host-event-listeners';
|
|
18
|
+
/**
|
|
19
|
+
* Who a capture's `contactCreated` names as its cause (AGL-3376): the door's
|
|
20
|
+
* own word when it gave one; otherwise the visitor themselves on a door where
|
|
21
|
+
* the person captured is the one acting — a form, a sign-up, a purchase, a
|
|
22
|
+
* booking. A door a member or a key drives (`manual`, `import`, `api`) passes
|
|
23
|
+
* its actor, and one that does not is left unrecorded rather than guessed.
|
|
24
|
+
*/
|
|
25
|
+
export declare function contactCaptureActor(source: string | null | undefined, email: string | null | undefined, explicit?: HostEventActor): HostEventActor | undefined;
|
|
@@ -0,0 +1,40 @@
|
|
|
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 { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
/** The capture sources in which the person captured is the one acting. */ const SELF_CAPTURE_SOURCES = new Set([
|
|
18
|
+
'form',
|
|
19
|
+
'member',
|
|
20
|
+
'order',
|
|
21
|
+
'booking',
|
|
22
|
+
'newsletter'
|
|
23
|
+
]);
|
|
24
|
+
/**
|
|
25
|
+
* Who a capture's `contactCreated` names as its cause (AGL-3376): the door's
|
|
26
|
+
* own word when it gave one; otherwise the visitor themselves on a door where
|
|
27
|
+
* the person captured is the one acting — a form, a sign-up, a purchase, a
|
|
28
|
+
* booking. A door a member or a key drives (`manual`, `import`, `api`) passes
|
|
29
|
+
* its actor, and one that does not is left unrecorded rather than guessed.
|
|
30
|
+
*/ export function contactCaptureActor(source, email, explicit) {
|
|
31
|
+
if (explicit == null ? void 0 : explicit.kind) return explicit;
|
|
32
|
+
if (!SELF_CAPTURE_SOURCES.has(String(source != null ? source : ''))) return undefined;
|
|
33
|
+
return _extends({
|
|
34
|
+
kind: 'visitor'
|
|
35
|
+
}, email ? {
|
|
36
|
+
email
|
|
37
|
+
} : {});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
//# sourceMappingURL=capture-actor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/capture-actor.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostEventActor } from './host-event-listeners'\n\n/** The capture sources in which the person captured is the one acting. */\nconst SELF_CAPTURE_SOURCES = new Set(['form', 'member', 'order', 'booking', 'newsletter'])\n\n/**\n * Who a capture's `contactCreated` names as its cause (AGL-3376): the door's\n * own word when it gave one; otherwise the visitor themselves on a door where\n * the person captured is the one acting — a form, a sign-up, a purchase, a\n * booking. A door a member or a key drives (`manual`, `import`, `api`) passes\n * its actor, and one that does not is left unrecorded rather than guessed.\n */\nexport function contactCaptureActor(\n source: string | null | undefined,\n email: string | null | undefined,\n explicit?: HostEventActor,\n): HostEventActor | undefined {\n if (explicit?.kind) return explicit\n if (!SELF_CAPTURE_SOURCES.has(String(source ?? ''))) return undefined\n return { kind: 'visitor', ...(email ? { email } : {}) }\n}\n"],"names":["SELF_CAPTURE_SOURCES","Set","contactCaptureActor","source","email","explicit","kind","has","String","undefined"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAID,wEAAwE,GACxE,MAAMA,uBAAuB,IAAIC,IAAI;IAAC;IAAQ;IAAU;IAAS;IAAW;CAAa;AAEzF;;;;;;CAMC,GACD,OAAO,SAASC,oBACdC,MAAiC,EACjCC,KAAgC,EAChCC,QAAyB;IAEzB,IAAIA,4BAAAA,SAAUC,IAAI,EAAE,OAAOD;IAC3B,IAAI,CAACL,qBAAqBO,GAAG,CAACC,OAAOL,iBAAAA,SAAU,MAAM,OAAOM;IAC5D,OAAO;QAAEH,MAAM;OAAeF,QAAQ;QAAEA;IAAM,IAAI,CAAC;AACrD"}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import { type HostContactCreated, type UpsertHostContactOptions, type UpsertHostContactVerdict } from '@aglyn/tenant-data-admin';
|
|
18
|
-
import type { HostEventPayload } from './host-event-listeners';
|
|
18
|
+
import type { HostEventActor, HostEventPayload } from './host-event-listeners';
|
|
19
19
|
/**
|
|
20
20
|
* THE contact capture door for a server path (AGL-2605).
|
|
21
21
|
*
|
|
@@ -76,7 +76,10 @@ import type { HostEventPayload } from './host-event-listeners';
|
|
|
76
76
|
* The order doors call this directly and close nothing — a buyer with an
|
|
77
77
|
* open lead is a lead the rep converts, as in Salesforce.
|
|
78
78
|
*/
|
|
79
|
-
export declare function captureHostContact(
|
|
79
|
+
export declare function captureHostContact(captureOptions: Omit<UpsertHostContactOptions, 'onCreated'> & {
|
|
80
|
+
/** Who caused the capture, when the door knows (AGL-3376). */
|
|
81
|
+
actor?: HostEventActor;
|
|
82
|
+
}): Promise<UpsertHostContactVerdict>;
|
|
80
83
|
/**
|
|
81
84
|
* The `contactCreated` payload, as the scalars an event may carry.
|
|
82
85
|
*
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
import { _ as _object_without_properties_loose } from "@swc/helpers/_/_object_without_properties_loose";
|
|
2
3
|
/**
|
|
3
4
|
* @license
|
|
4
5
|
* Copyright 2026 Aglyn LLC
|
|
@@ -18,6 +19,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
18
19
|
import { assignOwnerForCapture } from "./assign-contact-owner.js";
|
|
19
20
|
import { associateCompanyByDomain } from "./associate-company-by-domain.js";
|
|
20
21
|
import { emitHostEvent } from "./emit-host-event.js";
|
|
22
|
+
import { contactCaptureActor } from "./capture-actor.js";
|
|
21
23
|
/**
|
|
22
24
|
* THE contact capture door for a server path (AGL-2605).
|
|
23
25
|
*
|
|
@@ -77,7 +79,10 @@ import { emitHostEvent } from "./emit-host-event.js";
|
|
|
77
79
|
* its capture writer for the doors that report through the capture seam.
|
|
78
80
|
* The order doors call this directly and close nothing — a buyer with an
|
|
79
81
|
* open lead is a lead the rep converts, as in Salesforce.
|
|
80
|
-
*/ export async function captureHostContact(
|
|
82
|
+
*/ export async function captureHostContact(captureOptions) {
|
|
83
|
+
const { actor } = captureOptions, options = _object_without_properties_loose(captureOptions, [
|
|
84
|
+
"actor"
|
|
85
|
+
]);
|
|
81
86
|
return upsertHostContact(_extends({}, options, {
|
|
82
87
|
onCreated: async (created)=>{
|
|
83
88
|
var _options_facet, _options_facet1;
|
|
@@ -99,7 +104,10 @@ import { emitHostEvent } from "./emit-host-event.js";
|
|
|
99
104
|
console.error('captureHostContact owner assignment failed', error);
|
|
100
105
|
});
|
|
101
106
|
}
|
|
102
|
-
|
|
107
|
+
const cause = contactCaptureActor(created.source, created.email, actor);
|
|
108
|
+
await emitHostEvent(created.hostId, 'contactCreated', contactCreatedPayload(created, options.interaction.formId), cause ? {
|
|
109
|
+
actor: cause
|
|
110
|
+
} : {});
|
|
103
111
|
}
|
|
104
112
|
}));
|
|
105
113
|
}
|
|
@@ -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 *\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
|
|
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 { contactCaptureActor } from './capture-actor'\nimport type { HostEventActor, HostEventPayload } from './host-event-listeners'\n\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 captureOptions: Omit<UpsertHostContactOptions, 'onCreated'> & {\n /** Who caused the capture, when the door knows (AGL-3376). */\n actor?: HostEventActor\n },\n): Promise<UpsertHostContactVerdict> {\n const { actor, ...options } = captureOptions\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 const cause = contactCaptureActor(created.source, created.email, actor)\n await emitHostEvent(\n created.hostId,\n 'contactCreated',\n contactCreatedPayload(created, options.interaction.formId),\n cause ? { actor: cause } : {},\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","contactCaptureActor","captureHostContact","captureOptions","actor","options","onCreated","created","facet","companyId","catch","error","console","ownerUid","hostId","contactId","email","source","formId","interaction","tags","cause","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;AACjD,SAASC,mBAAmB,QAAQ,qBAAiB;AAIrD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2DC,GACD,OAAO,eAAeC,mBACpBC,cAGC;IAED,MAAM,EAAEC,KAAK,EAAc,GAAGD,gBAAZE,2CAAYF;;;IAC9B,OAAON,kBAAkB,aACpBQ;QACHC,WAAW,OAAOC;gBACXF,gBAKAA;YALL,IAAI,GAACA,iBAAAA,QAAQG,KAAK,qBAAbH,eAAeI,SAAS,GAAE;gBAC7B,MAAMV,yBAAyBQ,SAASG,KAAK,CAAC,CAACC;oBAC7CC,QAAQD,KAAK,CAAC,iDAAiDA;gBACjE;YACF;YACA,IAAI,GAACN,kBAAAA,QAAQG,KAAK,qBAAbH,gBAAeQ,QAAQ,GAAE;oBAMlBR;gBALV,MAAMP,sBAAsB;oBAC1BgB,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,MAAMU,QAAQpB,oBAAoBM,QAAQU,MAAM,EAAEV,QAAQS,KAAK,EAAEZ;YACjE,MAAMJ,cACJO,QAAQO,MAAM,EACd,kBACAQ,sBAAsBf,SAASF,QAAQc,WAAW,CAACD,MAAM,GACzDG,QAAQ;gBAAEjB,OAAOiB;YAAM,IAAI,CAAC;QAEhC;;AAEJ;AAEA;;;;;;;;;;;;;;;;;;;CAmBC,GACD,OAAO,SAASC,sBACdf,OAA2B,EAC3BW,MAAsB;QAMdX,eAGUA;IAPlB,MAAMgB,OAAOC,OAAON,iBAAAA,SAAU,IAAIO,IAAI;IACtC,OAAO;QACLV,WAAWR,QAAQQ,SAAS;QAC5BC,OAAOT,QAAQS,KAAK;QACpBU,IAAI,GAAEnB,gBAAAA,QAAQmB,IAAI,YAAZnB,gBAAgB;QACtBU,QAAQV,QAAQU,MAAM;QACtBH,QAAQP,QAAQO,MAAM;QACtBa,cAAc,GAAEpB,0BAAAA,QAAQoB,cAAc,YAAtBpB,0BAA0B;OACtCA,QAAQqB,WAAW,CAACC,MAAM,GAC1B;QAAED,aAAarB,QAAQqB,WAAW,CAACE,IAAI,CAAC;IAAK,IAC7C,CAAC,GACDP,OAAO;QAAEL,QAAQK;IAAK,IAAI,CAAC;AAEnC;AAEA,eAAerB,mBAAkB"}
|
|
@@ -105,7 +105,7 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
105
105
|
* Convert one lead. Throws only on infrastructure failure; every refusal
|
|
106
106
|
* the data can produce is a `ConvertHostLeadResult`.
|
|
107
107
|
*/ export async function convertHostLead(input) {
|
|
108
|
-
var _input_companyId, _leadSnapshot_data, _input_ownerUid, _contactSnapshot_data, _readContactFacet_ownerUid,
|
|
108
|
+
var _input_companyId, _leadSnapshot_data, _input_ownerUid, _actor_email, _actor_apiKeyName, _contactSnapshot_data, _readContactFacet_ownerUid, _actor_email1, _lead_name;
|
|
109
109
|
const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input;
|
|
110
110
|
const requestedCompanyId = String((_input_companyId = input.companyId) != null ? _input_companyId : '').trim();
|
|
111
111
|
// The lead's one home, on the org (AGL-3275). `orgId` is already resolved
|
|
@@ -193,6 +193,15 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
193
193
|
interaction: {
|
|
194
194
|
summary: 'Converted from a lead',
|
|
195
195
|
refId: leadId
|
|
196
|
+
},
|
|
197
|
+
// Who converted it, for the runs its contactCreated sets off (AGL-3376).
|
|
198
|
+
actor: actor.kind === 'member' ? {
|
|
199
|
+
kind: 'member',
|
|
200
|
+
uid: actor.uid,
|
|
201
|
+
email: (_actor_email = actor.email) != null ? _actor_email : null
|
|
202
|
+
} : {
|
|
203
|
+
kind: 'apiKey',
|
|
204
|
+
apiKeyName: (_actor_apiKeyName = actor.apiKeyName) != null ? _actor_apiKeyName : null
|
|
196
205
|
}
|
|
197
206
|
}, leadConsent.basis === 'granted' ? {
|
|
198
207
|
marketingConsent: true
|
|
@@ -486,7 +495,7 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
486
495
|
* and logs nothing.
|
|
487
496
|
*/ await logHostActivity(hostId, _extends({
|
|
488
497
|
uid: actor.uid,
|
|
489
|
-
email: (
|
|
498
|
+
email: (_actor_email1 = actor.email) != null ? _actor_email1 : null
|
|
490
499
|
}, actor.apiKeyName ? {
|
|
491
500
|
apiKeyName: actor.apiKeyName
|
|
492
501
|
} : {}), 'Converted lead', {
|
|
@@ -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 consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\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 { 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 // 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 ...(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 },\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 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 company: Record<string, unknown> = {\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) {\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 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 ...(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 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 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","crmScopeTokens","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","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","leadDisclosure","captured","name","source","interaction","summary","refId","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","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","createdByUid","createdAt","created","add","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","dealRecord","title","titleLower","status","amountCents","currency","stageChangedAtMs","deals","stamp","convertedAtMs","by","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 consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\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 { 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 // 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 },\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 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 company: Record<string, unknown> = {\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) {\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 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 ...(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 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 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","crmScopeTokens","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","handOffLeadRecords","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","actor","contactSnapshot","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","leadDisclosure","captured","name","source","interaction","summary","refId","uid","apiKeyName","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","createdByUid","createdAt","created","add","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","dealRecord","title","titleLower","status","amountCents","currency","stageChangedAtMs","deals","stamp","convertedAtMs","by","type"],"mappings":"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"}
|
|
@@ -15,15 +15,18 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
17
|
import type { HostActionAlert, HostEventType } from '@aglyn/aglyn/server';
|
|
18
|
-
import { type HostEventPayload } from './host-event-listeners';
|
|
18
|
+
import { type HostEventContext, type HostEventPayload } from './host-event-listeners';
|
|
19
19
|
/**
|
|
20
20
|
* One emit point for host events (AGL-148): hands the event to every
|
|
21
21
|
* registered host-event listener (see `host-event-listeners.ts`) and returns
|
|
22
22
|
* the site alerts they produced, so request/response emitters (form submit,
|
|
23
23
|
* booking) can surface them; fire-and-forget emitters ignore the result. No
|
|
24
24
|
* listener throws into the emitting request.
|
|
25
|
+
*
|
|
26
|
+
* `context.actor` is who caused the event (AGL-3376), for the run history's
|
|
27
|
+
* Who; a door that knows should always say.
|
|
25
28
|
*/
|
|
26
|
-
export declare function emitHostEvent(hostId: string, event: HostEventType, payload?: HostEventPayload): Promise<{
|
|
29
|
+
export declare function emitHostEvent(hostId: string, event: HostEventType, payload?: HostEventPayload, context?: HostEventContext): Promise<{
|
|
27
30
|
alerts: HostActionAlert[];
|
|
28
31
|
}>;
|
|
29
32
|
export default emitHostEvent;
|
|
@@ -20,9 +20,12 @@
|
|
|
20
20
|
* the site alerts they produced, so request/response emitters (form submit,
|
|
21
21
|
* booking) can surface them; fire-and-forget emitters ignore the result. No
|
|
22
22
|
* listener throws into the emitting request.
|
|
23
|
-
|
|
23
|
+
*
|
|
24
|
+
* `context.actor` is who caused the event (AGL-3376), for the run history's
|
|
25
|
+
* Who; a door that knows should always say.
|
|
26
|
+
*/ export async function emitHostEvent(hostId, event, payload = {}, context = {}) {
|
|
24
27
|
return {
|
|
25
|
-
alerts: await runHostEventListeners(hostId, event, payload)
|
|
28
|
+
alerts: await runHostEventListeners(hostId, event, payload, context)
|
|
26
29
|
};
|
|
27
30
|
}
|
|
28
31
|
export default emitHostEvent;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/emit-host-event.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostActionAlert, HostEventType } from '@aglyn/aglyn/server'\nimport {\n type HostEventPayload,\n runHostEventListeners,\n} from './host-event-listeners'\n\n/**\n * One emit point for host events (AGL-148): hands the event to every\n * registered host-event listener (see `host-event-listeners.ts`) and returns\n * the site alerts they produced, so request/response emitters (form submit,\n * booking) can surface them; fire-and-forget emitters ignore the result. No\n * listener throws into the emitting request.\n */\nexport async function emitHostEvent(\n hostId: string,\n event: HostEventType,\n payload: HostEventPayload = {},\n): Promise<{ alerts: HostActionAlert[] }> {\n return { alerts: await runHostEventListeners(hostId, event, payload) }\n}\n\nexport default emitHostEvent\n"],"names":["runHostEventListeners","emitHostEvent","hostId","event","payload","alerts"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/emit-host-event.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostActionAlert, HostEventType } from '@aglyn/aglyn/server'\nimport {\n type HostEventContext,\n type HostEventPayload,\n runHostEventListeners,\n} from './host-event-listeners'\n\n/**\n * One emit point for host events (AGL-148): hands the event to every\n * registered host-event listener (see `host-event-listeners.ts`) and returns\n * the site alerts they produced, so request/response emitters (form submit,\n * booking) can surface them; fire-and-forget emitters ignore the result. No\n * listener throws into the emitting request.\n *\n * `context.actor` is who caused the event (AGL-3376), for the run history's\n * Who; a door that knows should always say.\n */\nexport async function emitHostEvent(\n hostId: string,\n event: HostEventType,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<{ alerts: HostActionAlert[] }> {\n return { alerts: await runHostEventListeners(hostId, event, payload, context) }\n}\n\nexport default emitHostEvent\n"],"names":["runHostEventListeners","emitHostEvent","hostId","event","payload","context","alerts"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SAGEA,qBAAqB,QAChB,4BAAwB;AAE/B;;;;;;;;;CASC,GACD,OAAO,eAAeC,cACpBC,MAAc,EACdC,KAAoB,EACpBC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,OAAO;QAAEC,QAAQ,MAAMN,sBAAsBE,QAAQC,OAAOC,SAASC;IAAS;AAChF;AAEA,eAAeJ,cAAa"}
|
|
@@ -44,6 +44,30 @@ import type { HostActionAlert } from '@aglyn/aglyn/server';
|
|
|
44
44
|
*/
|
|
45
45
|
/** What an event carries into everything that listens for it. */
|
|
46
46
|
export type HostEventPayload = Record<string, string | number | boolean>;
|
|
47
|
+
/**
|
|
48
|
+
* Who caused an event (AGL-3376): the console member who moved the deal, the
|
|
49
|
+
* integration's key that added the contact, the visitor who submitted the
|
|
50
|
+
* form, or the platform acting on its own (a Stripe webhook, a schedule).
|
|
51
|
+
*
|
|
52
|
+
* Kept OUT of the payload on purpose. The payload seeds a workflow's
|
|
53
|
+
* expression scope and condition editor, and a site's automations have no
|
|
54
|
+
* business reading a console member's address; this rides beside it, for
|
|
55
|
+
* the record of who set a run off.
|
|
56
|
+
*/
|
|
57
|
+
export interface HostEventActor {
|
|
58
|
+
kind: 'member' | 'visitor' | 'apiKey' | 'platform';
|
|
59
|
+
/** The acting account, for a member. */
|
|
60
|
+
uid?: string | null;
|
|
61
|
+
/** The address the actor had when they acted, when the door knew it. */
|
|
62
|
+
email?: string | null;
|
|
63
|
+
/** The key's name, for an integration. */
|
|
64
|
+
apiKeyName?: string | null;
|
|
65
|
+
}
|
|
66
|
+
/** What rides beside an event's payload. */
|
|
67
|
+
export interface HostEventContext {
|
|
68
|
+
/** Absent when the door that raised the event did not know. */
|
|
69
|
+
actor?: HostEventActor;
|
|
70
|
+
}
|
|
47
71
|
export interface HostEventListener {
|
|
48
72
|
/**
|
|
49
73
|
* Runs whatever this plugin does when `event` happens on the site.
|
|
@@ -51,7 +75,7 @@ export interface HostEventListener {
|
|
|
51
75
|
* Resolves the site alerts a request/response emitter shows its visitor —
|
|
52
76
|
* a form submission returns them in its response — or nothing.
|
|
53
77
|
*/
|
|
54
|
-
onEvent(hostId: string, event: string, payload: HostEventPayload): Promise<readonly HostActionAlert[] | void>;
|
|
78
|
+
onEvent(hostId: string, event: string, payload: HostEventPayload, context?: HostEventContext): Promise<readonly HostActionAlert[] | void>;
|
|
55
79
|
/**
|
|
56
80
|
* Runs ONE automation, by id, that a published page fired for a site event
|
|
57
81
|
* it evaluated itself — a scroll depth, a click, exit intent. The page has
|
|
@@ -59,7 +83,7 @@ export interface HostEventListener {
|
|
|
59
83
|
* automation rather than every automation on the event; a listener that
|
|
60
84
|
* does not own the id resolves nothing.
|
|
61
85
|
*/
|
|
62
|
-
onDispatch?(hostId: string, automationId: string, event: string, payload: HostEventPayload): Promise<readonly HostActionAlert[] | void>;
|
|
86
|
+
onDispatch?(hostId: string, automationId: string, event: string, payload: HostEventPayload, context?: HostEventContext): Promise<readonly HostActionAlert[] | void>;
|
|
63
87
|
}
|
|
64
88
|
/**
|
|
65
89
|
* Subscribes a plugin to every host event. Registering again under the same
|
|
@@ -75,9 +99,9 @@ export declare function resetHostEventListenersForTests(): void;
|
|
|
75
99
|
* Hands one event to every listener, concurrently, and gathers the alerts
|
|
76
100
|
* they produced. Never rejects.
|
|
77
101
|
*/
|
|
78
|
-
export declare function runHostEventListeners(hostId: string, event: string, payload?: HostEventPayload): Promise<HostActionAlert[]>;
|
|
102
|
+
export declare function runHostEventListeners(hostId: string, event: string, payload?: HostEventPayload, context?: HostEventContext): Promise<HostActionAlert[]>;
|
|
79
103
|
/**
|
|
80
104
|
* Hands one page-dispatched automation to the listeners that dispatch, and
|
|
81
105
|
* gathers the alerts it produced. Never rejects.
|
|
82
106
|
*/
|
|
83
|
-
export declare function dispatchHostAutomation(hostId: string, automationId: string, event: string, payload?: HostEventPayload): Promise<HostActionAlert[]>;
|
|
107
|
+
export declare function dispatchHostAutomation(hostId: string, automationId: string, event: string, payload?: HostEventPayload, context?: HostEventContext): Promise<HostActionAlert[]>;
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
/**
|
|
50
50
|
* Hands one event to every listener, concurrently, and gathers the alerts
|
|
51
51
|
* they produced. Never rejects.
|
|
52
|
-
*/ export async function runHostEventListeners(hostId, event, payload = {}) {
|
|
52
|
+
*/ export async function runHostEventListeners(hostId, event, payload = {}, context = {}) {
|
|
53
53
|
if (!registrations.length) {
|
|
54
54
|
/*
|
|
55
55
|
* Said once per process rather than per event. No listener means the
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
}
|
|
65
65
|
const settled = await Promise.all(registrations.map(async ({ pluginId, listener })=>{
|
|
66
66
|
try {
|
|
67
|
-
return alertsOf(await listener.onEvent(hostId, event, payload));
|
|
67
|
+
return alertsOf(await listener.onEvent(hostId, event, payload, context));
|
|
68
68
|
} catch (error) {
|
|
69
69
|
console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error);
|
|
70
70
|
return [];
|
|
@@ -75,12 +75,12 @@
|
|
|
75
75
|
/**
|
|
76
76
|
* Hands one page-dispatched automation to the listeners that dispatch, and
|
|
77
77
|
* gathers the alerts it produced. Never rejects.
|
|
78
|
-
*/ export async function dispatchHostAutomation(hostId, automationId, event, payload = {}) {
|
|
78
|
+
*/ export async function dispatchHostAutomation(hostId, automationId, event, payload = {}, context = {}) {
|
|
79
79
|
const alerts = [];
|
|
80
80
|
for (const { pluginId, listener } of registrations){
|
|
81
81
|
if (!listener.onDispatch) continue;
|
|
82
82
|
try {
|
|
83
|
-
alerts.push(...alertsOf(await listener.onDispatch(hostId, automationId, event, payload)));
|
|
83
|
+
alerts.push(...alertsOf(await listener.onDispatch(hostId, automationId, event, payload, context)));
|
|
84
84
|
} catch (error) {
|
|
85
85
|
console.error(`[host-events] ${pluginId} failed to dispatch ${automationId}`, hostId, error);
|
|
86
86
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/host-event-listeners.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostActionAlert } from '@aglyn/aglyn/server'\n\n/**\n * WHO HEARS A HOST EVENT.\n *\n * A host event — a form submitted, a member signed up, a deal won — is raised\n * by the server door that performed the write, through `emitHostEvent`. What\n * runs because of it is not the runtime's business: an automation engine, an\n * integration, a marketplace plugin. Each registers a listener here and the\n * runtime fans every event out to all of them, knowing none by name.\n *\n * ## Registered by a call, at boot\n *\n * A listener is registered by a function the platform calls BY NAME — a\n * plugin's `serverDeclarations` entry, which both apps run once per process\n * from `instrumentation.ts`, and its API register functions, which the plugin\n * loader runs. Never by a bare `import './x'`: a bundler drops a module its\n * package does not list as a side effect, and jest does not, so a registration\n * that relies on one passes every spec and runs in no build (AGL-3025).\n *\n * Boot registration is what lets a core route that never loads a plugin — the\n * form submission, the page-view collector — still reach every listener.\n *\n * ## Isolated, and never thrown into the emitter\n *\n * The event has already happened: the submission is stored, the member exists.\n * A listener that throws is logged against its plugin and the others still\n * run, and the emitting request never sees the failure.\n */\n\n/** What an event carries into everything that listens for it. */\nexport type HostEventPayload = Record<string, string | number | boolean>\n\nexport interface HostEventListener {\n /**\n * Runs whatever this plugin does when `event` happens on the site.\n *\n * Resolves the site alerts a request/response emitter shows its visitor —\n * a form submission returns them in its response — or nothing.\n */\n onEvent(\n hostId: string,\n event: string,\n payload: HostEventPayload,\n ): Promise<readonly HostActionAlert[] | void>\n /**\n * Runs ONE automation, by id, that a published page fired for a site event\n * it evaluated itself — a scroll depth, a click, exit intent. The page has\n * already decided the trigger matched, so the listener runs that one\n * automation rather than every automation on the event; a listener that\n * does not own the id resolves nothing.\n */\n onDispatch?(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload,\n ): Promise<readonly HostActionAlert[] | void>\n}\n\ninterface Registration {\n pluginId: string\n listener: HostEventListener\n}\n\nconst registrations: Registration[] = []\n\n/** Whether the no-listener warning has been printed in this process. */\nlet warnedEmpty = false\n\n/** A listener's answer as a list: nothing it said counts as no alerts. */\nfunction alertsOf(answer: readonly HostActionAlert[] | void): HostActionAlert[] {\n return Array.isArray(answer) ? [...answer] : []\n}\n\n/**\n * Subscribes a plugin to every host event. Registering again under the same\n * plugin id replaces the earlier listener in place, so a register function\n * the platform calls twice — boot, then the API surface — leaves one.\n */\nexport function registerHostEventListener(\n pluginId: string,\n listener: HostEventListener,\n): void {\n const id = String(pluginId ?? '').trim()\n if (!id) {\n throw new Error('a host event listener needs the id of the plugin it belongs to')\n }\n const at = registrations.findIndex((entry) => entry.pluginId === id)\n if (at >= 0) registrations[at] = { pluginId: id, listener }\n else registrations.push({ pluginId: id, listener })\n}\n\n/** The plugins listening, in registration order, for diagnostics. */\nexport function listHostEventListeners(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every listener. */\nexport function resetHostEventListenersForTests(): void {\n registrations.length = 0\n warnedEmpty = false\n}\n\n/**\n * Hands one event to every listener, concurrently, and gathers the alerts\n * they produced. Never rejects.\n */\nexport async function runHostEventListeners(\n hostId: string,\n event: string,\n payload: HostEventPayload = {},\n): Promise<HostActionAlert[]> {\n if (!registrations.length) {\n /*\n * Said once per process rather than per event. No listener means the\n * plugins' server declarations never ran here — every automation on\n * every site served by this process is silent, and a line in the log is\n * the only place that is visible.\n */\n if (!warnedEmpty) {\n warnedEmpty = true\n console.warn(\n `[host-events] nothing is listening; \"${event}\" on ${hostId} ran no ` +\n 'automation. Listeners register from the plugins’ server ' +\n 'declarations at boot.',\n )\n }\n return []\n }\n const settled = await Promise.all(\n registrations.map(async ({ pluginId, listener }) => {\n try {\n return alertsOf(await listener.onEvent(hostId, event, payload))\n } catch (error) {\n console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error)\n return []\n }\n }),\n )\n return settled.flat()\n}\n\n/**\n * Hands one page-dispatched automation to the listeners that dispatch, and\n * gathers the alerts it produced. Never rejects.\n */\nexport async function dispatchHostAutomation(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload = {},\n): Promise<HostActionAlert[]> {\n const alerts: HostActionAlert[] = []\n for (const { pluginId, listener } of registrations) {\n if (!listener.onDispatch) continue\n try {\n alerts.push(\n ...alertsOf(await listener.onDispatch(hostId, automationId, event, payload)),\n )\n } catch (error) {\n console.error(\n `[host-events] ${pluginId} failed to dispatch ${automationId}`,\n hostId,\n error,\n )\n }\n }\n return alerts\n}\n"],"names":["registrations","warnedEmpty","alertsOf","answer","Array","isArray","registerHostEventListener","pluginId","listener","id","String","trim","Error","at","findIndex","entry","push","listHostEventListeners","map","resetHostEventListenersForTests","length","runHostEventListeners","hostId","event","payload","console","warn","settled","Promise","all","onEvent","error","flat","dispatchHostAutomation","automationId","alerts","onDispatch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAmED,MAAMA,gBAAgC,EAAE;AAExC,sEAAsE,GACtE,IAAIC,cAAc;AAElB,wEAAwE,GACxE,SAASC,SAASC,MAAyC;IACzD,OAAOC,MAAMC,OAAO,CAACF,UAAU;WAAIA;KAAO,GAAG,EAAE;AACjD;AAEA;;;;CAIC,GACD,OAAO,SAASG,0BACdC,QAAgB,EAChBC,QAA2B;IAE3B,MAAMC,KAAKC,OAAOH,mBAAAA,WAAY,IAAII,IAAI;IACtC,IAAI,CAACF,IAAI;QACP,MAAM,IAAIG,MAAM;IAClB;IACA,MAAMC,KAAKb,cAAcc,SAAS,CAAC,CAACC,QAAUA,MAAMR,QAAQ,KAAKE;IACjE,IAAII,MAAM,GAAGb,aAAa,CAACa,GAAG,GAAG;QAAEN,UAAUE;QAAID;IAAS;SACrDR,cAAcgB,IAAI,CAAC;QAAET,UAAUE;QAAID;IAAS;AACnD;AAEA,mEAAmE,GACnE,OAAO,SAASS;IACd,OAAOjB,cAAckB,GAAG,CAAC,CAACH,QAAUA,MAAMR,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASY;IACdnB,cAAcoB,MAAM,GAAG;IACvBnB,cAAc;AAChB;AAEA;;;CAGC,GACD,OAAO,eAAeoB,sBACpBC,MAAc,EACdC,KAAa,EACbC,UAA4B,CAAC,CAAC;IAE9B,IAAI,CAACxB,cAAcoB,MAAM,EAAE;QACzB;;;;;KAKC,GACD,IAAI,CAACnB,aAAa;YAChBA,cAAc;YACdwB,QAAQC,IAAI,CACV,CAAC,qCAAqC,EAAEH,MAAM,KAAK,EAAED,OAAO,QAAQ,CAAC,GACnE,6DACA;QAEN;QACA,OAAO,EAAE;IACX;IACA,MAAMK,UAAU,MAAMC,QAAQC,GAAG,CAC/B7B,cAAckB,GAAG,CAAC,OAAO,EAAEX,QAAQ,EAAEC,QAAQ,EAAE;QAC7C,IAAI;YACF,OAAON,SAAS,MAAMM,SAASsB,OAAO,CAACR,QAAQC,OAAOC;QACxD,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CAAC,CAAC,cAAc,EAAExB,SAAS,WAAW,EAAEgB,OAAO,EAAED,QAAQS;YACtE,OAAO,EAAE;QACX;IACF;IAEF,OAAOJ,QAAQK,IAAI;AACrB;AAEA;;;CAGC,GACD,OAAO,eAAeC,uBACpBX,MAAc,EACdY,YAAoB,EACpBX,KAAa,EACbC,UAA4B,CAAC,CAAC;IAE9B,MAAMW,SAA4B,EAAE;IACpC,KAAK,MAAM,EAAE5B,QAAQ,EAAEC,QAAQ,EAAE,IAAIR,cAAe;QAClD,IAAI,CAACQ,SAAS4B,UAAU,EAAE;QAC1B,IAAI;YACFD,OAAOnB,IAAI,IACNd,SAAS,MAAMM,SAAS4B,UAAU,CAACd,QAAQY,cAAcX,OAAOC;QAEvE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CACX,CAAC,cAAc,EAAExB,SAAS,oBAAoB,EAAE2B,cAAc,EAC9DZ,QACAS;QAEJ;IACF;IACA,OAAOI;AACT"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/host-event-listeners.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostActionAlert } from '@aglyn/aglyn/server'\n\n/**\n * WHO HEARS A HOST EVENT.\n *\n * A host event — a form submitted, a member signed up, a deal won — is raised\n * by the server door that performed the write, through `emitHostEvent`. What\n * runs because of it is not the runtime's business: an automation engine, an\n * integration, a marketplace plugin. Each registers a listener here and the\n * runtime fans every event out to all of them, knowing none by name.\n *\n * ## Registered by a call, at boot\n *\n * A listener is registered by a function the platform calls BY NAME — a\n * plugin's `serverDeclarations` entry, which both apps run once per process\n * from `instrumentation.ts`, and its API register functions, which the plugin\n * loader runs. Never by a bare `import './x'`: a bundler drops a module its\n * package does not list as a side effect, and jest does not, so a registration\n * that relies on one passes every spec and runs in no build (AGL-3025).\n *\n * Boot registration is what lets a core route that never loads a plugin — the\n * form submission, the page-view collector — still reach every listener.\n *\n * ## Isolated, and never thrown into the emitter\n *\n * The event has already happened: the submission is stored, the member exists.\n * A listener that throws is logged against its plugin and the others still\n * run, and the emitting request never sees the failure.\n */\n\n/** What an event carries into everything that listens for it. */\nexport type HostEventPayload = Record<string, string | number | boolean>\n\n/**\n * Who caused an event (AGL-3376): the console member who moved the deal, the\n * integration's key that added the contact, the visitor who submitted the\n * form, or the platform acting on its own (a Stripe webhook, a schedule).\n *\n * Kept OUT of the payload on purpose. The payload seeds a workflow's\n * expression scope and condition editor, and a site's automations have no\n * business reading a console member's address; this rides beside it, for\n * the record of who set a run off.\n */\nexport interface HostEventActor {\n kind: 'member' | 'visitor' | 'apiKey' | 'platform'\n /** The acting account, for a member. */\n uid?: string | null\n /** The address the actor had when they acted, when the door knew it. */\n email?: string | null\n /** The key's name, for an integration. */\n apiKeyName?: string | null\n}\n\n/** What rides beside an event's payload. */\nexport interface HostEventContext {\n /** Absent when the door that raised the event did not know. */\n actor?: HostEventActor\n}\n\nexport interface HostEventListener {\n /**\n * Runs whatever this plugin does when `event` happens on the site.\n *\n * Resolves the site alerts a request/response emitter shows its visitor —\n * a form submission returns them in its response — or nothing.\n */\n onEvent(\n hostId: string,\n event: string,\n payload: HostEventPayload,\n context?: HostEventContext,\n ): Promise<readonly HostActionAlert[] | void>\n /**\n * Runs ONE automation, by id, that a published page fired for a site event\n * it evaluated itself — a scroll depth, a click, exit intent. The page has\n * already decided the trigger matched, so the listener runs that one\n * automation rather than every automation on the event; a listener that\n * does not own the id resolves nothing.\n */\n onDispatch?(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload,\n context?: HostEventContext,\n ): Promise<readonly HostActionAlert[] | void>\n}\n\ninterface Registration {\n pluginId: string\n listener: HostEventListener\n}\n\nconst registrations: Registration[] = []\n\n/** Whether the no-listener warning has been printed in this process. */\nlet warnedEmpty = false\n\n/** A listener's answer as a list: nothing it said counts as no alerts. */\nfunction alertsOf(answer: readonly HostActionAlert[] | void): HostActionAlert[] {\n return Array.isArray(answer) ? [...answer] : []\n}\n\n/**\n * Subscribes a plugin to every host event. Registering again under the same\n * plugin id replaces the earlier listener in place, so a register function\n * the platform calls twice — boot, then the API surface — leaves one.\n */\nexport function registerHostEventListener(\n pluginId: string,\n listener: HostEventListener,\n): void {\n const id = String(pluginId ?? '').trim()\n if (!id) {\n throw new Error('a host event listener needs the id of the plugin it belongs to')\n }\n const at = registrations.findIndex((entry) => entry.pluginId === id)\n if (at >= 0) registrations[at] = { pluginId: id, listener }\n else registrations.push({ pluginId: id, listener })\n}\n\n/** The plugins listening, in registration order, for diagnostics. */\nexport function listHostEventListeners(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every listener. */\nexport function resetHostEventListenersForTests(): void {\n registrations.length = 0\n warnedEmpty = false\n}\n\n/**\n * Hands one event to every listener, concurrently, and gathers the alerts\n * they produced. Never rejects.\n */\nexport async function runHostEventListeners(\n hostId: string,\n event: string,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<HostActionAlert[]> {\n if (!registrations.length) {\n /*\n * Said once per process rather than per event. No listener means the\n * plugins' server declarations never ran here — every automation on\n * every site served by this process is silent, and a line in the log is\n * the only place that is visible.\n */\n if (!warnedEmpty) {\n warnedEmpty = true\n console.warn(\n `[host-events] nothing is listening; \"${event}\" on ${hostId} ran no ` +\n 'automation. Listeners register from the plugins’ server ' +\n 'declarations at boot.',\n )\n }\n return []\n }\n const settled = await Promise.all(\n registrations.map(async ({ pluginId, listener }) => {\n try {\n return alertsOf(await listener.onEvent(hostId, event, payload, context))\n } catch (error) {\n console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error)\n return []\n }\n }),\n )\n return settled.flat()\n}\n\n/**\n * Hands one page-dispatched automation to the listeners that dispatch, and\n * gathers the alerts it produced. Never rejects.\n */\nexport async function dispatchHostAutomation(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<HostActionAlert[]> {\n const alerts: HostActionAlert[] = []\n for (const { pluginId, listener } of registrations) {\n if (!listener.onDispatch) continue\n try {\n alerts.push(\n ...alertsOf(\n await listener.onDispatch(hostId, automationId, event, payload, context),\n ),\n )\n } catch (error) {\n console.error(\n `[host-events] ${pluginId} failed to dispatch ${automationId}`,\n hostId,\n error,\n )\n }\n }\n return alerts\n}\n"],"names":["registrations","warnedEmpty","alertsOf","answer","Array","isArray","registerHostEventListener","pluginId","listener","id","String","trim","Error","at","findIndex","entry","push","listHostEventListeners","map","resetHostEventListenersForTests","length","runHostEventListeners","hostId","event","payload","context","console","warn","settled","Promise","all","onEvent","error","flat","dispatchHostAutomation","automationId","alerts","onDispatch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA+FD,MAAMA,gBAAgC,EAAE;AAExC,sEAAsE,GACtE,IAAIC,cAAc;AAElB,wEAAwE,GACxE,SAASC,SAASC,MAAyC;IACzD,OAAOC,MAAMC,OAAO,CAACF,UAAU;WAAIA;KAAO,GAAG,EAAE;AACjD;AAEA;;;;CAIC,GACD,OAAO,SAASG,0BACdC,QAAgB,EAChBC,QAA2B;IAE3B,MAAMC,KAAKC,OAAOH,mBAAAA,WAAY,IAAII,IAAI;IACtC,IAAI,CAACF,IAAI;QACP,MAAM,IAAIG,MAAM;IAClB;IACA,MAAMC,KAAKb,cAAcc,SAAS,CAAC,CAACC,QAAUA,MAAMR,QAAQ,KAAKE;IACjE,IAAII,MAAM,GAAGb,aAAa,CAACa,GAAG,GAAG;QAAEN,UAAUE;QAAID;IAAS;SACrDR,cAAcgB,IAAI,CAAC;QAAET,UAAUE;QAAID;IAAS;AACnD;AAEA,mEAAmE,GACnE,OAAO,SAASS;IACd,OAAOjB,cAAckB,GAAG,CAAC,CAACH,QAAUA,MAAMR,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASY;IACdnB,cAAcoB,MAAM,GAAG;IACvBnB,cAAc;AAChB;AAEA;;;CAGC,GACD,OAAO,eAAeoB,sBACpBC,MAAc,EACdC,KAAa,EACbC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,IAAI,CAACzB,cAAcoB,MAAM,EAAE;QACzB;;;;;KAKC,GACD,IAAI,CAACnB,aAAa;YAChBA,cAAc;YACdyB,QAAQC,IAAI,CACV,CAAC,qCAAqC,EAAEJ,MAAM,KAAK,EAAED,OAAO,QAAQ,CAAC,GACnE,6DACA;QAEN;QACA,OAAO,EAAE;IACX;IACA,MAAMM,UAAU,MAAMC,QAAQC,GAAG,CAC/B9B,cAAckB,GAAG,CAAC,OAAO,EAAEX,QAAQ,EAAEC,QAAQ,EAAE;QAC7C,IAAI;YACF,OAAON,SAAS,MAAMM,SAASuB,OAAO,CAACT,QAAQC,OAAOC,SAASC;QACjE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CAAC,CAAC,cAAc,EAAEzB,SAAS,WAAW,EAAEgB,OAAO,EAAED,QAAQU;YACtE,OAAO,EAAE;QACX;IACF;IAEF,OAAOJ,QAAQK,IAAI;AACrB;AAEA;;;CAGC,GACD,OAAO,eAAeC,uBACpBZ,MAAc,EACda,YAAoB,EACpBZ,KAAa,EACbC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,MAAMW,SAA4B,EAAE;IACpC,KAAK,MAAM,EAAE7B,QAAQ,EAAEC,QAAQ,EAAE,IAAIR,cAAe;QAClD,IAAI,CAACQ,SAAS6B,UAAU,EAAE;QAC1B,IAAI;YACFD,OAAOpB,IAAI,IACNd,SACD,MAAMM,SAAS6B,UAAU,CAACf,QAAQa,cAAcZ,OAAOC,SAASC;QAGtE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CACX,CAAC,cAAc,EAAEzB,SAAS,oBAAoB,EAAE4B,cAAc,EAC9Db,QACAU;QAEJ;IACF;IACA,OAAOI;AACT"}
|