@aglyn/plugins-crm 1.0.0-beta.195 → 1.0.0-beta.197
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/contacts-section.js +18 -3
- package/src/lib/components/contacts-section.js.map +1 -1
- package/src/lib/components/leads-section.js +2 -2
- package/src/lib/components/leads-section.js.map +1 -1
- package/src/lib/components/new-contact-drawer.d.ts +6 -0
- package/src/lib/components/new-contact-drawer.js +3 -2
- package/src/lib/components/new-contact-drawer.js.map +1 -1
- package/src/lib/constants/contact-filters.js +6 -0
- package/src/lib/constants/contact-filters.js.map +1 -1
- package/src/lib/declarations.server.js +21 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/hooks/use-crm-scope.d.ts +10 -0
- package/src/lib/hooks/use-crm-scope.js +32 -4
- package/src/lib/hooks/use-crm-scope.js.map +1 -1
- package/src/lib/model/consent-group-summary.d.ts +25 -0
- package/src/lib/model/consent-group-summary.js +32 -0
- package/src/lib/model/consent-group-summary.js.map +1 -0
- package/src/lib/server/capture-contact.js +16 -2
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/consent-group-participant.d.ts +81 -0
- package/src/lib/server/consent-group-participant.js +605 -0
- package/src/lib/server/consent-group-participant.js.map +1 -0
- package/src/lib/server.d.ts +2 -1
- package/src/lib/server.js +10 -2
- package/src/lib/server.js.map +1 -1
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ // The contract from its own module, not the plugin-manager barrel: boot needs
|
|
17
17
|
// the registry and nothing else, and the barrel reaches the client contexts.
|
|
18
|
+
import { registerPluginConsentGroupParticipant } from "@aglyn/aglyn/plugin-manager/plugin-consent-group-change";
|
|
18
19
|
import { registerPluginContactCaptureWriter } from "@aglyn/aglyn/plugin-manager/plugin-contact-capture";
|
|
19
20
|
import { registerPluginLeadConversionListener } from "@aglyn/aglyn/plugin-manager/plugin-lead-conversion";
|
|
20
21
|
import { registerPluginPersonMatcher } from "@aglyn/aglyn/plugin-manager/plugin-person-matches";
|
|
21
22
|
import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
23
|
+
import { summarizeConsentGroupChange } from "./model/consent-group-summary.js";
|
|
22
24
|
/**
|
|
23
25
|
* The CRM as the plugin that keeps people.
|
|
24
26
|
*
|
|
@@ -82,6 +84,25 @@ import { BUNDLE_ID } from "./constants/bundle-common.js";
|
|
|
82
84
|
}, {
|
|
83
85
|
pluginId: BUNDLE_ID
|
|
84
86
|
});
|
|
87
|
+
// The CRM's share of a consent group change (AGL-3320): a contact's
|
|
88
|
+
// per-site refusal carried across a separation, and the records keyed by a
|
|
89
|
+
// group re-homed after the flip. Registered whether or not a workspace has
|
|
90
|
+
// the CRM switched on, because the contacts — and the refusals on them —
|
|
91
|
+
// outlive the switch. Deferred like the rest; the summary is plain words
|
|
92
|
+
// and answers synchronously.
|
|
93
|
+
registerPluginConsentGroupParticipant({
|
|
94
|
+
async preview (request) {
|
|
95
|
+
const { consentGroupParticipant } = await import("./server/consent-group-participant.js");
|
|
96
|
+
return consentGroupParticipant.preview(request);
|
|
97
|
+
},
|
|
98
|
+
async run (request) {
|
|
99
|
+
const { consentGroupParticipant } = await import("./server/consent-group-participant.js");
|
|
100
|
+
return consentGroupParticipant.run(request);
|
|
101
|
+
},
|
|
102
|
+
summarize: summarizeConsentGroupChange
|
|
103
|
+
}, {
|
|
104
|
+
pluginId: BUNDLE_ID
|
|
105
|
+
});
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
//# sourceMappingURL=declarations.server.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport { BUNDLE_ID } from './constants/bundle-common'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n}\n"],"names":["registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","BUNDLE_ID","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/plugins/crm/src/lib/declarations.server.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n// The contract from its own module, not the plugin-manager barrel: boot needs\n// the registry and nothing else, and the barrel reaches the client contexts.\nimport { registerPluginConsentGroupParticipant } from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change'\nimport {\n registerPluginContactCaptureWriter,\n type PluginContactCaptureWriter,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport { registerPluginLeadConversionListener } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { registerPluginPersonMatcher } from '@aglyn/aglyn/plugin-manager/plugin-person-matches'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { summarizeConsentGroupChange } from './model/consent-group-summary'\n\n/**\n * The CRM as the plugin that keeps people.\n *\n * Light by construction, the way the workflows listener is: the capture\n * itself is imported when the first one arrives, not when the process\n * starts, so a process that never captures anybody pays for this object and\n * nothing else. That matters because this registers in EVERY server process,\n * including ones that will never touch the CRM.\n */\nexport const crmContactCaptureWriter: PluginContactCaptureWriter = {\n async capture(request) {\n const { captureContactForCrm } = await import('./server/capture-contact')\n return await captureContactForCrm(request)\n },\n}\n\n/**\n * The plugin's SERVER declarations: what the server must know at boot, before\n * any door of the CRM has been called.\n *\n * ## Why a declaration and not a `tenantApi` registration\n *\n * The doors that meet a person mostly never load this plugin. A form\n * submission is a core route; an order and a booking are captured inside\n * STRIPE WEBHOOKS. None of them has a reason to load the CRM, and a registry\n * filled by a surface that did not load answers `null` — which\n * `capturePluginContact` defines as \"this workspace has no record system\", a\n * sentence that would be false and silent. Orders would stop creating\n * contacts with nothing red anywhere, which is the AGL-3025 shape.\n *\n * Running from both apps' generated server-declarations manifest at boot is\n * what makes the answer true in every process. It is also called from the\n * plugin's own API register functions, so a process whose boot did not run it\n * still registers the writer the first time a CRM door loads. Registering\n * twice replaces in place.\n *\n * ⚠️ The registry holds ONE writer: a workspace keeps one set of people. A\n * second plugin's is refused naming both, and the incumbent keeps serving.\n */\nexport function registerCrmServerDeclarations(): void {\n registerPluginContactCaptureWriter(crmContactCaptureWriter, {\n pluginId: BUNDLE_ID,\n })\n // The CRM's own share of a lead conversion (AGL-3254): the lead's\n // campaigns go onto the contact's facet. Through the seam every door\n // that converts a lead reaches, and deferred like the capture: the\n // module that writes is loaded when the first conversion arrives, and\n // it brings the Admin SDK with it — this file defers the plugin's OWN\n // module only. A library imported statically across the plugin cannot\n // also be lazy-loaded here: `enforce-module-boundaries` refuses every\n // static import of a library the project lazy-loads anywhere.\n registerPluginLeadConversionListener(\n async (request) => {\n const { carryLeadCampaignsOnConversion } =\n await import('./server/lead-campaign-carry')\n return carryLeadCampaignsOnConversion(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // \"Did this workspace already know this person?\" (AGL-3289), asked by the\n // staff console's acquisition card. Deferred like the rest: the reads load\n // when a staff member first opens a card, not when the process starts.\n registerPluginPersonMatcher(\n async (request) => {\n const { matchCrmPeople } = await import('./server/person-matches')\n return matchCrmPeople(request)\n },\n { pluginId: BUNDLE_ID },\n )\n // The CRM's share of a consent group change (AGL-3320): a contact's\n // per-site refusal carried across a separation, and the records keyed by a\n // group re-homed after the flip. Registered whether or not a workspace has\n // the CRM switched on, because the contacts — and the refusals on them —\n // outlive the switch. Deferred like the rest; the summary is plain words\n // and answers synchronously.\n registerPluginConsentGroupParticipant(\n {\n async preview(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.preview(request)\n },\n async run(request) {\n const { consentGroupParticipant } = await import('./server/consent-group-participant')\n return consentGroupParticipant.run(request)\n },\n summarize: summarizeConsentGroupChange,\n },\n { pluginId: BUNDLE_ID },\n )\n}\n\n"],"names":["registerPluginConsentGroupParticipant","registerPluginContactCaptureWriter","registerPluginLeadConversionListener","registerPluginPersonMatcher","BUNDLE_ID","summarizeConsentGroupChange","crmContactCaptureWriter","capture","request","captureContactForCrm","registerCrmServerDeclarations","pluginId","carryLeadCampaignsOnConversion","matchCrmPeople","preview","consentGroupParticipant","run","summarize"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,8EAA8E;AAC9E,6EAA6E;AAC7E,SAASA,qCAAqC,QAAQ,0DAAyD;AAC/G,SACEC,kCAAkC,QAE7B,qDAAoD;AAC3D,SAASC,oCAAoC,QAAQ,qDAAoD;AACzG,SAASC,2BAA2B,QAAQ,oDAAmD;AAC/F,SAASC,SAAS,QAAQ,+BAA2B;AACrD,SAASC,2BAA2B,QAAQ,mCAA+B;AAE3E;;;;;;;;CAQC,GACD,OAAO,MAAMC,0BAAsD;IACjE,MAAMC,SAAQC,OAAO;QACnB,MAAM,EAAEC,oBAAoB,EAAE,GAAG,MAAM,MAAM,CAAC;QAC9C,OAAO,MAAMA,qBAAqBD;IACpC;AACF,EAAC;AAED;;;;;;;;;;;;;;;;;;;;;;CAsBC,GACD,OAAO,SAASE;IACdT,mCAAmCK,yBAAyB;QAC1DK,UAAUP;IACZ;IACA,kEAAkE;IAClE,qEAAqE;IACrE,mEAAmE;IACnE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,sEAAsE;IACtE,8DAA8D;IAC9DF,qCACE,OAAOM;QACL,MAAM,EAAEI,8BAA8B,EAAE,GACtC,MAAM,MAAM,CAAC;QACf,OAAOA,+BAA+BJ;IACxC,GACA;QAAEG,UAAUP;IAAU;IAExB,0EAA0E;IAC1E,2EAA2E;IAC3E,uEAAuE;IACvED,4BACE,OAAOK;QACL,MAAM,EAAEK,cAAc,EAAE,GAAG,MAAM,MAAM,CAAC;QACxC,OAAOA,eAAeL;IACxB,GACA;QAAEG,UAAUP;IAAU;IAExB,oEAAoE;IACpE,2EAA2E;IAC3E,2EAA2E;IAC3E,yEAAyE;IACzE,yEAAyE;IACzE,6BAA6B;IAC7BJ,sCACE;QACE,MAAMc,SAAQN,OAAO;YACnB,MAAM,EAAEO,uBAAuB,EAAE,GAAG,MAAM,MAAM,CAAC;YACjD,OAAOA,wBAAwBD,OAAO,CAACN;QACzC;QACA,MAAMQ,KAAIR,OAAO;YACf,MAAM,EAAEO,uBAAuB,EAAE,GAAG,MAAM,MAAM,CAAC;YACjD,OAAOA,wBAAwBC,GAAG,CAACR;QACrC;QACAS,WAAWZ;IACb,GACA;QAAEM,UAAUP;IAAU;AAE1B"}
|
|
@@ -34,6 +34,16 @@ export interface CrmScope {
|
|
|
34
34
|
* it to {@link crmVisibleToClause}, which spells both cases. Stable across
|
|
35
35
|
* renders for the same org and site, so it can sit in a query's
|
|
36
36
|
* dependency list.
|
|
37
|
+
*
|
|
38
|
+
* On a site in a declared consent group, a member who reaches only SOME of
|
|
39
|
+
* its sites lists with the group's tokens narrowed to their own (AGL-3320):
|
|
40
|
+
* the rules admit a row by `visibleTo.hasAny(memberTokens)`, so a query
|
|
41
|
+
* naming a sibling the member cannot reach is one they cannot prove for a
|
|
42
|
+
* row stamped with that sibling alone, and Firestore refuses the whole
|
|
43
|
+
* list. Until the member's reach has loaded it is the SITE's own tokens,
|
|
44
|
+
* which everybody who can open this site's CRM holds — never the group's,
|
|
45
|
+
* which would be exactly that refused query, and never empty, which
|
|
46
|
+
* Firestore refuses to build at all.
|
|
37
47
|
*/
|
|
38
48
|
visibleTo: readonly ScopeToken[] | null;
|
|
39
49
|
/**
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ 'use client';
|
|
17
|
-
import { consentGroupForHost, crmReadTokens, crmScopeTokens } from "@aglyn/aglyn";
|
|
18
|
-
import { useOrgDataScope } from "@aglyn/tenant-feature-instance";
|
|
17
|
+
import { consentGroupForHost, crmReadTokens, crmScopeTokens, soloConsentGroup } from "@aglyn/aglyn";
|
|
18
|
+
import { useOrgDataScope, useScopeTokens } from "@aglyn/tenant-feature-instance";
|
|
19
19
|
import { where } from "firebase/firestore";
|
|
20
20
|
import { useMemo } from "react";
|
|
21
21
|
import { useCrmOrgMount } from "./use-crm-org-mount.js";
|
|
@@ -73,8 +73,36 @@ const NO_TOKENS = Object.freeze([]);
|
|
|
73
73
|
org,
|
|
74
74
|
hostId
|
|
75
75
|
]);
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
/*
|
|
77
|
+
* WHO IS READING, for a site that shares a consent group (AGL-3320).
|
|
78
|
+
*
|
|
79
|
+
* A site alone reads `['org', 'host:{id}']`, and every member who can open
|
|
80
|
+
* that site's CRM holds both — so the member's own tokens could only ever
|
|
81
|
+
* return the same list, and the read is skipped: `useScopeTokens` opens
|
|
82
|
+
* nothing for an undefined org. A DECLARED group's tokens name its other
|
|
83
|
+
* sites too, and a collaborator invited to one of them does not hold the
|
|
84
|
+
* rest.
|
|
85
|
+
*/ const readsSiblings = (consentGroup == null ? void 0 : consentGroup.declared) === true;
|
|
86
|
+
const reach = useScopeTokens(readsSiblings && orgId ? orgId : undefined);
|
|
87
|
+
const visibleTo = useMemo(()=>{
|
|
88
|
+
if (!consentGroup) return null;
|
|
89
|
+
const tokens = crmReadTokens(consentGroup);
|
|
90
|
+
if (!readsSiblings) return tokens;
|
|
91
|
+
// The site's own, until the reader's reach answers: provable for anybody
|
|
92
|
+
// on this site, and an org-wide reader's list widens when it does.
|
|
93
|
+
if (!orgId || !reach.loaded) {
|
|
94
|
+
return crmReadTokens(soloConsentGroup(consentGroup.hostId));
|
|
95
|
+
}
|
|
96
|
+
if (reach.orgWide) return tokens;
|
|
97
|
+
const held = new Set(reach.tokens);
|
|
98
|
+
return tokens.filter((token)=>held.has(token));
|
|
99
|
+
}, [
|
|
100
|
+
consentGroup,
|
|
101
|
+
readsSiblings,
|
|
102
|
+
orgId,
|
|
103
|
+
reach.loaded,
|
|
104
|
+
reach.orgWide,
|
|
105
|
+
reach.tokens
|
|
78
106
|
]);
|
|
79
107
|
const createHostId = (_ref = hostId != null ? hostId : mount == null ? void 0 : mount.createHostId) != null ? _ref : null;
|
|
80
108
|
const createGroup = useMemo(()=>createHostId ? consentGroupForHost(org, createHostId) : null, [
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-scope.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'use client'\n\nimport {\n type ConsentGroup,\n type ConsolePluginPageProps,\n consentGroupForHost,\n crmReadTokens,\n crmScopeTokens,\n type ScopeToken,\n} from '@aglyn/aglyn'\nimport { useOrgDataScope } from '@aglyn/tenant-feature-instance'\nimport { type QueryConstraint, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from './use-crm-org-mount'\n\n/**\n * The org document as a CRM surface receives it: the shell's typed billing\n * projection on a page, or the plain record a card was handed. Read as a\n * record at the one seam below, so no component casts it itself.\n */\nexport type CrmOrgDoc =\n | ConsolePluginPageProps['org']\n | Record<string, unknown>\n | null\n | undefined\n\n/** Where a CRM surface is mounted: under one site, or over the whole org. */\nexport type CrmScopeLevel = 'site' | 'org'\n\nexport interface CrmScope {\n /** The org data root, or `null` until the org lookup settles or when there is none. */\n scope: readonly ['orgs', string] | null\n /** The owning org, or null while the lookup runs or when there is none. */\n orgId: string | null\n /** Whether the org lookup has settled — `orgId` null after this means no org. */\n ready: boolean\n /** Under a site, or at the organization level (AGL-2630). */\n level: CrmScopeLevel\n /** The mounted site, or `null` at the organization level. */\n hostId: string | null\n /**\n * The controller this surface is being viewed as, or `null` at the org\n * level, where there is no one viewing site: a cross-holder reader\n * resolves each contact's own facet instead (`contactPrimaryGroup`).\n */\n consentGroup: ConsentGroup | null\n /**\n * What this viewer may LIST — every listener's `array-contains-any`\n * predicate, capped at what the operator accepts — or `null` at the org\n * level, where an org-wide member's listeners carry NO scope clause: the\n * rules' `canReadScoped()` short-circuits on `isOrgWideMember()`, so the\n * clause would only ever narrow what the reader is already allowed. Hand\n * it to {@link crmVisibleToClause}, which spells both cases. Stable across\n * renders for the same org and site, so it can sit in a query's\n * dependency list.\n */\n visibleTo: readonly ScopeToken[] | null\n /**\n * The site a record this viewer CREATES is captured by: the mounted site,\n * or at the org level the site the reader picked (`null` until they\n * have). A create must hold until this is known — the tokens below are\n * empty without it, and a record stamped with none is visible to nobody.\n */\n createHostId: string | null\n /** The consent group of {@link createHostId} — what a create records consent for. */\n createGroup: ConsentGroup | null\n /** What a record this viewer CREATES is stamped with; empty until {@link createHostId} is known. */\n createTokens: readonly ScopeToken[]\n}\n\nconst NO_TOKENS: readonly ScopeToken[] = Object.freeze([])\n\n/**\n * The scope facts every CRM surface needs, resolved once (AGL-2597, one\n * hook since AGL-2614, two levels since AGL-2630).\n *\n * A CRM surface — a list, a record page, a drawer, a card on somebody\n * else's page — resolves its consent group, builds the token list its\n * listener filters on, builds the tokens a create stamps, and looks up the\n * org root. Every section needs the same answers, and for a while the deals\n * had a hook of their own that answered them under different names\n * (`readTokens` for `visibleTo`), which is two chances to filter by one set\n * of tokens and stamp with another. This is the one place the expression\n * lives: a surface that spelled its own could omit the `'org'` token and\n * stop listing the org-wide records the rules would have let it read.\n *\n * ## Two levels, one call\n *\n * Under a site, `hostId` names it and everything follows from the org\n * document and that id, as it always has. At the ORGANIZATION level the\n * hub hands every surface `hostId: null`, and the org comes from the mount\n * the hub published (`useCrmOrgMount`): the org root is known at once,\n * reads are unscoped, and the site a create stamps from is the one the\n * reader picked. A surface written against `{ scope, visibleTo,\n * createTokens }` serves both levels without knowing which it is on — the\n * two differences it cannot ignore are that `visibleTo` and `consentGroup`\n * are `null` at the org level, and both are typed to say so.\n *\n * `visibleTo` and `createTokens` are different questions with different\n * answers. The first is the union of everything this viewer may see; the\n * second is where a new record lands, which `crmScopeTokens` decides from\n * the org's default and is narrower — a group of one site creates for that\n * site, and reads the org-wide rows beside its own.\n *\n * Pure over the org document the shell already passed, so it costs no read;\n * only the org lookup behind `useOrgDataScope` is asynchronous, and `ready`\n * is how a surface tells \"not yet\" from \"no org\".\n */\nexport function useCrmScope(props: {\n hostId: string | null | undefined\n org?: CrmOrgDoc\n}): CrmScope {\n const hostId = props.hostId || null\n const org = (props.org ?? null) as Record<string, unknown> | null\n const mount = useCrmOrgMount()\n // Under a site the org is looked up from the host index; at the org level\n // it is the mount's, known at once. A `null` host with no mount is a\n // surface mounted nowhere, and resolves to no org rather than guessing.\n const { scope, orgId, ready } = useOrgDataScope(\n hostId ? { hostId } : { orgId: mount?.orgId },\n )\n const level: CrmScopeLevel = hostId ? 'site' : 'org'\n const consentGroup = useMemo(\n () => (hostId ? consentGroupForHost(org, hostId) : null),\n [org, hostId],\n )\n const visibleTo = useMemo(\n () => (consentGroup ? crmReadTokens(consentGroup) : null),\n [consentGroup],\n )\n const createHostId = hostId ?? mount?.createHostId ?? null\n const createGroup = useMemo(\n () => (createHostId ? consentGroupForHost(org, createHostId) : null),\n [org, createHostId],\n )\n const createTokens = useMemo(\n () => (createGroup ? crmScopeTokens(org, createGroup) : NO_TOKENS),\n [org, createGroup],\n )\n return useMemo(\n () => ({\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n }),\n [\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n ],\n )\n}\n\n/**\n * The scope clause a CRM listener carries, spelled once for both levels\n * (AGL-2630).\n *\n * Under a site: `where('visibleTo', 'array-contains-any', tokens)`, the\n * predicate the rules evaluate with `hasAny`, which is what makes a\n * filtered query provable per document. At the org level: NOTHING — the\n * reader is an org-wide member, the rules admit them to every row, and a\n * query that kept the clause would need the org's whole site list on it\n * and still miss a site the list did not carry. Spread into the query, so\n * the two cases are one line at every listener: `...crmVisibleToClause(\n * scope.visibleTo)`.\n *\n * A query that drops an `array-contains-any` may need a composite index\n * the scoped form did not: the equality and range clauses beside it stay,\n * and Firestore composes them on their own. The org-level entries live in\n * `cloud/firebase-firestore.indexes.json` beside their scoped twins.\n */\nexport function crmVisibleToClause(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): QueryConstraint[] {\n if (!visibleTo) return []\n return [where('visibleTo', 'array-contains-any', [...visibleTo])]\n}\n\n/**\n * Whether a listener that filters on `visibleTo` has anything to ask for:\n * `true` at the org level (no clause is a complete question), and under a\n * site only when the token list is non-empty — an `array-contains-any`\n * over nothing is a query Firestore refuses.\n */\nexport function crmScopeListable(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): boolean {\n return visibleTo === null || visibleTo === undefined || visibleTo.length > 0\n}\n\nexport default useCrmScope\n"],"names":["consentGroupForHost","crmReadTokens","crmScopeTokens","useOrgDataScope","where","useMemo","useCrmOrgMount","NO_TOKENS","Object","freeze","useCrmScope","props","hostId","org","mount","scope","orgId","ready","level","consentGroup","visibleTo","createHostId","createGroup","createTokens","crmVisibleToClause","crmScopeListable","undefined","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAGEA,mBAAmB,EACnBC,aAAa,EACbC,cAAc,QAET,eAAc;AACrB,SAASC,eAAe,QAAQ,iCAAgC;AAChE,SAA+BC,KAAK,QAAQ,qBAAoB;AAChE,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,cAAc,QAAQ,yBAAqB;AAyDpD,MAAMC,YAAmCC,OAAOC,MAAM,CAAC,EAAE;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,SAASC,YAAYC,KAG3B;QAEcA,YAiBQC;IAlBrB,MAAMA,SAASD,MAAMC,MAAM,IAAI;IAC/B,MAAMC,OAAOF,aAAAA,MAAME,GAAG,YAATF,aAAa;IAC1B,MAAMG,QAAQR;IACd,0EAA0E;IAC1E,qEAAqE;IACrE,wEAAwE;IACxE,MAAM,EAAES,KAAK,EAAEC,KAAK,EAAEC,KAAK,EAAE,GAAGd,gBAC9BS,SAAS;QAAEA;IAAO,IAAI;QAAEI,KAAK,EAAEF,yBAAAA,MAAOE,KAAK;IAAC;IAE9C,MAAME,QAAuBN,SAAS,SAAS;IAC/C,MAAMO,eAAed,QACnB,IAAOO,SAASZ,oBAAoBa,KAAKD,UAAU,MACnD;QAACC;QAAKD;KAAO;IAEf,MAAMQ,YAAYf,QAChB,IAAOc,eAAelB,cAAckB,gBAAgB,MACpD;QAACA;KAAa;IAEhB,MAAME,gBAAeT,OAAAA,iBAAAA,SAAUE,yBAAAA,MAAOO,YAAY,YAA7BT,OAAiC;IACtD,MAAMU,cAAcjB,QAClB,IAAOgB,eAAerB,oBAAoBa,KAAKQ,gBAAgB,MAC/D;QAACR;QAAKQ;KAAa;IAErB,MAAME,eAAelB,QACnB,IAAOiB,cAAcpB,eAAeW,KAAKS,eAAef,WACxD;QAACM;QAAKS;KAAY;IAEpB,OAAOjB,QACL,IAAO,CAAA;YACLU;YACAC;YACAC;YACAC;YACAN;YACAO;YACAC;YACAC;YACAC;YACAC;QACF,CAAA,GACA;QACER;QACAC;QACAC;QACAC;QACAN;QACAO;QACAC;QACAC;QACAC;QACAC;KACD;AAEL;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,mBACdJ,SAAuE;IAEvE,IAAI,CAACA,WAAW,OAAO,EAAE;IACzB,OAAO;QAAChB,MAAM,aAAa,sBAAsB;eAAIgB;SAAU;KAAE;AACnE;AAEA;;;;;CAKC,GACD,OAAO,SAASK,iBACdL,SAAuE;IAEvE,OAAOA,cAAc,QAAQA,cAAcM,aAAaN,UAAUO,MAAM,GAAG;AAC7E;AAEA,eAAejB,YAAW"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/hooks/use-crm-scope.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'use client'\n\nimport {\n type ConsentGroup,\n type ConsolePluginPageProps,\n consentGroupForHost,\n crmReadTokens,\n crmScopeTokens,\n type ScopeToken,\n soloConsentGroup,\n} from '@aglyn/aglyn'\nimport { useOrgDataScope, useScopeTokens } from '@aglyn/tenant-feature-instance'\nimport { type QueryConstraint, where } from 'firebase/firestore'\nimport { useMemo } from 'react'\nimport { useCrmOrgMount } from './use-crm-org-mount'\n\n/**\n * The org document as a CRM surface receives it: the shell's typed billing\n * projection on a page, or the plain record a card was handed. Read as a\n * record at the one seam below, so no component casts it itself.\n */\nexport type CrmOrgDoc =\n | ConsolePluginPageProps['org']\n | Record<string, unknown>\n | null\n | undefined\n\n/** Where a CRM surface is mounted: under one site, or over the whole org. */\nexport type CrmScopeLevel = 'site' | 'org'\n\nexport interface CrmScope {\n /** The org data root, or `null` until the org lookup settles or when there is none. */\n scope: readonly ['orgs', string] | null\n /** The owning org, or null while the lookup runs or when there is none. */\n orgId: string | null\n /** Whether the org lookup has settled — `orgId` null after this means no org. */\n ready: boolean\n /** Under a site, or at the organization level (AGL-2630). */\n level: CrmScopeLevel\n /** The mounted site, or `null` at the organization level. */\n hostId: string | null\n /**\n * The controller this surface is being viewed as, or `null` at the org\n * level, where there is no one viewing site: a cross-holder reader\n * resolves each contact's own facet instead (`contactPrimaryGroup`).\n */\n consentGroup: ConsentGroup | null\n /**\n * What this viewer may LIST — every listener's `array-contains-any`\n * predicate, capped at what the operator accepts — or `null` at the org\n * level, where an org-wide member's listeners carry NO scope clause: the\n * rules' `canReadScoped()` short-circuits on `isOrgWideMember()`, so the\n * clause would only ever narrow what the reader is already allowed. Hand\n * it to {@link crmVisibleToClause}, which spells both cases. Stable across\n * renders for the same org and site, so it can sit in a query's\n * dependency list.\n *\n * On a site in a declared consent group, a member who reaches only SOME of\n * its sites lists with the group's tokens narrowed to their own (AGL-3320):\n * the rules admit a row by `visibleTo.hasAny(memberTokens)`, so a query\n * naming a sibling the member cannot reach is one they cannot prove for a\n * row stamped with that sibling alone, and Firestore refuses the whole\n * list. Until the member's reach has loaded it is the SITE's own tokens,\n * which everybody who can open this site's CRM holds — never the group's,\n * which would be exactly that refused query, and never empty, which\n * Firestore refuses to build at all.\n */\n visibleTo: readonly ScopeToken[] | null\n /**\n * The site a record this viewer CREATES is captured by: the mounted site,\n * or at the org level the site the reader picked (`null` until they\n * have). A create must hold until this is known — the tokens below are\n * empty without it, and a record stamped with none is visible to nobody.\n */\n createHostId: string | null\n /** The consent group of {@link createHostId} — what a create records consent for. */\n createGroup: ConsentGroup | null\n /** What a record this viewer CREATES is stamped with; empty until {@link createHostId} is known. */\n createTokens: readonly ScopeToken[]\n}\n\nconst NO_TOKENS: readonly ScopeToken[] = Object.freeze([])\n\n/**\n * The scope facts every CRM surface needs, resolved once (AGL-2597, one\n * hook since AGL-2614, two levels since AGL-2630).\n *\n * A CRM surface — a list, a record page, a drawer, a card on somebody\n * else's page — resolves its consent group, builds the token list its\n * listener filters on, builds the tokens a create stamps, and looks up the\n * org root. Every section needs the same answers, and for a while the deals\n * had a hook of their own that answered them under different names\n * (`readTokens` for `visibleTo`), which is two chances to filter by one set\n * of tokens and stamp with another. This is the one place the expression\n * lives: a surface that spelled its own could omit the `'org'` token and\n * stop listing the org-wide records the rules would have let it read.\n *\n * ## Two levels, one call\n *\n * Under a site, `hostId` names it and everything follows from the org\n * document and that id, as it always has. At the ORGANIZATION level the\n * hub hands every surface `hostId: null`, and the org comes from the mount\n * the hub published (`useCrmOrgMount`): the org root is known at once,\n * reads are unscoped, and the site a create stamps from is the one the\n * reader picked. A surface written against `{ scope, visibleTo,\n * createTokens }` serves both levels without knowing which it is on — the\n * two differences it cannot ignore are that `visibleTo` and `consentGroup`\n * are `null` at the org level, and both are typed to say so.\n *\n * `visibleTo` and `createTokens` are different questions with different\n * answers. The first is the union of everything this viewer may see; the\n * second is where a new record lands, which `crmScopeTokens` decides from\n * the org's default and is narrower — a group of one site creates for that\n * site, and reads the org-wide rows beside its own.\n *\n * Pure over the org document the shell already passed, so it costs no read;\n * only the org lookup behind `useOrgDataScope` is asynchronous, and `ready`\n * is how a surface tells \"not yet\" from \"no org\".\n */\nexport function useCrmScope(props: {\n hostId: string | null | undefined\n org?: CrmOrgDoc\n}): CrmScope {\n const hostId = props.hostId || null\n const org = (props.org ?? null) as Record<string, unknown> | null\n const mount = useCrmOrgMount()\n // Under a site the org is looked up from the host index; at the org level\n // it is the mount's, known at once. A `null` host with no mount is a\n // surface mounted nowhere, and resolves to no org rather than guessing.\n const { scope, orgId, ready } = useOrgDataScope(\n hostId ? { hostId } : { orgId: mount?.orgId },\n )\n const level: CrmScopeLevel = hostId ? 'site' : 'org'\n const consentGroup = useMemo(\n () => (hostId ? consentGroupForHost(org, hostId) : null),\n [org, hostId],\n )\n /*\n * WHO IS READING, for a site that shares a consent group (AGL-3320).\n *\n * A site alone reads `['org', 'host:{id}']`, and every member who can open\n * that site's CRM holds both — so the member's own tokens could only ever\n * return the same list, and the read is skipped: `useScopeTokens` opens\n * nothing for an undefined org. A DECLARED group's tokens name its other\n * sites too, and a collaborator invited to one of them does not hold the\n * rest.\n */\n const readsSiblings = consentGroup?.declared === true\n const reach = useScopeTokens(readsSiblings && orgId ? orgId : undefined)\n const visibleTo = useMemo(() => {\n if (!consentGroup) return null\n const tokens = crmReadTokens(consentGroup)\n if (!readsSiblings) return tokens\n // The site's own, until the reader's reach answers: provable for anybody\n // on this site, and an org-wide reader's list widens when it does.\n if (!orgId || !reach.loaded) {\n return crmReadTokens(soloConsentGroup(consentGroup.hostId))\n }\n if (reach.orgWide) return tokens\n const held = new Set<string>(reach.tokens)\n return tokens.filter((token) => held.has(token))\n }, [consentGroup, readsSiblings, orgId, reach.loaded, reach.orgWide, reach.tokens])\n const createHostId = hostId ?? mount?.createHostId ?? null\n const createGroup = useMemo(\n () => (createHostId ? consentGroupForHost(org, createHostId) : null),\n [org, createHostId],\n )\n const createTokens = useMemo(\n () => (createGroup ? crmScopeTokens(org, createGroup) : NO_TOKENS),\n [org, createGroup],\n )\n return useMemo(\n () => ({\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n }),\n [\n scope,\n orgId,\n ready,\n level,\n hostId,\n consentGroup,\n visibleTo,\n createHostId,\n createGroup,\n createTokens,\n ],\n )\n}\n\n/**\n * The scope clause a CRM listener carries, spelled once for both levels\n * (AGL-2630).\n *\n * Under a site: `where('visibleTo', 'array-contains-any', tokens)`, the\n * predicate the rules evaluate with `hasAny`, which is what makes a\n * filtered query provable per document. At the org level: NOTHING — the\n * reader is an org-wide member, the rules admit them to every row, and a\n * query that kept the clause would need the org's whole site list on it\n * and still miss a site the list did not carry. Spread into the query, so\n * the two cases are one line at every listener: `...crmVisibleToClause(\n * scope.visibleTo)`.\n *\n * A query that drops an `array-contains-any` may need a composite index\n * the scoped form did not: the equality and range clauses beside it stay,\n * and Firestore composes them on their own. The org-level entries live in\n * `cloud/firebase-firestore.indexes.json` beside their scoped twins.\n */\nexport function crmVisibleToClause(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): QueryConstraint[] {\n if (!visibleTo) return []\n return [where('visibleTo', 'array-contains-any', [...visibleTo])]\n}\n\n/**\n * Whether a listener that filters on `visibleTo` has anything to ask for:\n * `true` at the org level (no clause is a complete question), and under a\n * site only when the token list is non-empty — an `array-contains-any`\n * over nothing is a query Firestore refuses.\n */\nexport function crmScopeListable(\n visibleTo: readonly ScopeToken[] | readonly string[] | null | undefined,\n): boolean {\n return visibleTo === null || visibleTo === undefined || visibleTo.length > 0\n}\n\nexport default useCrmScope\n"],"names":["consentGroupForHost","crmReadTokens","crmScopeTokens","soloConsentGroup","useOrgDataScope","useScopeTokens","where","useMemo","useCrmOrgMount","NO_TOKENS","Object","freeze","useCrmScope","props","hostId","org","mount","scope","orgId","ready","level","consentGroup","readsSiblings","declared","reach","undefined","visibleTo","tokens","loaded","orgWide","held","Set","filter","token","has","createHostId","createGroup","createTokens","crmVisibleToClause","crmScopeListable","length"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GACD;AAEA,SAGEA,mBAAmB,EACnBC,aAAa,EACbC,cAAc,EAEdC,gBAAgB,QACX,eAAc;AACrB,SAASC,eAAe,EAAEC,cAAc,QAAQ,iCAAgC;AAChF,SAA+BC,KAAK,QAAQ,qBAAoB;AAChE,SAASC,OAAO,QAAQ,QAAO;AAC/B,SAASC,cAAc,QAAQ,yBAAqB;AAmEpD,MAAMC,YAAmCC,OAAOC,MAAM,CAAC,EAAE;AAEzD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,SAASC,YAAYC,KAG3B;QAEcA,YAsCQC;IAvCrB,MAAMA,SAASD,MAAMC,MAAM,IAAI;IAC/B,MAAMC,OAAOF,aAAAA,MAAME,GAAG,YAATF,aAAa;IAC1B,MAAMG,QAAQR;IACd,0EAA0E;IAC1E,qEAAqE;IACrE,wEAAwE;IACxE,MAAM,EAAES,KAAK,EAAEC,KAAK,EAAEC,KAAK,EAAE,GAAGf,gBAC9BU,SAAS;QAAEA;IAAO,IAAI;QAAEI,KAAK,EAAEF,yBAAAA,MAAOE,KAAK;IAAC;IAE9C,MAAME,QAAuBN,SAAS,SAAS;IAC/C,MAAMO,eAAed,QACnB,IAAOO,SAASd,oBAAoBe,KAAKD,UAAU,MACnD;QAACC;QAAKD;KAAO;IAEf;;;;;;;;;GASC,GACD,MAAMQ,gBAAgBD,CAAAA,gCAAAA,aAAcE,QAAQ,MAAK;IACjD,MAAMC,QAAQnB,eAAeiB,iBAAiBJ,QAAQA,QAAQO;IAC9D,MAAMC,YAAYnB,QAAQ;QACxB,IAAI,CAACc,cAAc,OAAO;QAC1B,MAAMM,SAAS1B,cAAcoB;QAC7B,IAAI,CAACC,eAAe,OAAOK;QAC3B,yEAAyE;QACzE,mEAAmE;QACnE,IAAI,CAACT,SAAS,CAACM,MAAMI,MAAM,EAAE;YAC3B,OAAO3B,cAAcE,iBAAiBkB,aAAaP,MAAM;QAC3D;QACA,IAAIU,MAAMK,OAAO,EAAE,OAAOF;QAC1B,MAAMG,OAAO,IAAIC,IAAYP,MAAMG,MAAM;QACzC,OAAOA,OAAOK,MAAM,CAAC,CAACC,QAAUH,KAAKI,GAAG,CAACD;IAC3C,GAAG;QAACZ;QAAcC;QAAeJ;QAAOM,MAAMI,MAAM;QAAEJ,MAAMK,OAAO;QAAEL,MAAMG,MAAM;KAAC;IAClF,MAAMQ,gBAAerB,OAAAA,iBAAAA,SAAUE,yBAAAA,MAAOmB,YAAY,YAA7BrB,OAAiC;IACtD,MAAMsB,cAAc7B,QAClB,IAAO4B,eAAenC,oBAAoBe,KAAKoB,gBAAgB,MAC/D;QAACpB;QAAKoB;KAAa;IAErB,MAAME,eAAe9B,QACnB,IAAO6B,cAAclC,eAAea,KAAKqB,eAAe3B,WACxD;QAACM;QAAKqB;KAAY;IAEpB,OAAO7B,QACL,IAAO,CAAA;YACLU;YACAC;YACAC;YACAC;YACAN;YACAO;YACAK;YACAS;YACAC;YACAC;QACF,CAAA,GACA;QACEpB;QACAC;QACAC;QACAC;QACAN;QACAO;QACAK;QACAS;QACAC;QACAC;KACD;AAEL;AAEA;;;;;;;;;;;;;;;;;CAiBC,GACD,OAAO,SAASC,mBACdZ,SAAuE;IAEvE,IAAI,CAACA,WAAW,OAAO,EAAE;IACzB,OAAO;QAACpB,MAAM,aAAa,sBAAsB;eAAIoB;SAAU;KAAE;AACnE;AAEA;;;;;CAKC,GACD,OAAO,SAASa,iBACdb,SAAuE;IAEvE,OAAOA,cAAc,QAAQA,cAAcD,aAAaC,UAAUc,MAAM,GAAG;AAC7E;AAEA,eAAe5B,YAAW"}
|
|
@@ -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
|
+
/**
|
|
18
|
+
* The CRM's clause of the "Finished a consent group change" activity line
|
|
19
|
+
* (AGL-3320), from the counts its participant reported over the whole change.
|
|
20
|
+
*
|
|
21
|
+
* Its own module, and plain words only, because the declarations register it
|
|
22
|
+
* at boot and answer it synchronously: the participant itself is loaded when
|
|
23
|
+
* a change first runs.
|
|
24
|
+
*/
|
|
25
|
+
export declare function summarizeConsentGroupChange(counts: Readonly<Record<string, number>>): string | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* The CRM's clause of the "Finished a consent group change" activity line
|
|
18
|
+
* (AGL-3320), from the counts its participant reported over the whole change.
|
|
19
|
+
*
|
|
20
|
+
* Its own module, and plain words only, because the declarations register it
|
|
21
|
+
* at boot and answer it synchronously: the participant itself is loaded when
|
|
22
|
+
* a change first runs.
|
|
23
|
+
*/ export function summarizeConsentGroupChange(counts) {
|
|
24
|
+
var _counts_combined, _counts_copied, _counts_refusals;
|
|
25
|
+
const combined = (_counts_combined = counts['combined']) != null ? _counts_combined : 0;
|
|
26
|
+
const copied = (_counts_copied = counts['copied']) != null ? _counts_copied : 0;
|
|
27
|
+
const refusals = (_counts_refusals = counts['refusals']) != null ? _counts_refusals : 0;
|
|
28
|
+
const clause = `${combined} CRM ${combined === 1 ? 'record' : 'records'} combined, ${copied} copied`;
|
|
29
|
+
return refusals ? `${clause}; ${refusals} declined ${refusals === 1 ? 'consent' : 'consents'} copied` : clause;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
//# sourceMappingURL=consent-group-summary.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/model/consent-group-summary.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * The CRM's clause of the \"Finished a consent group change\" activity line\n * (AGL-3320), from the counts its participant reported over the whole change.\n *\n * Its own module, and plain words only, because the declarations register it\n * at boot and answer it synchronously: the participant itself is loaded when\n * a change first runs.\n */\nexport function summarizeConsentGroupChange(\n counts: Readonly<Record<string, number>>,\n): string | null {\n const combined = counts['combined'] ?? 0\n const copied = counts['copied'] ?? 0\n const refusals = counts['refusals'] ?? 0\n const clause = `${combined} CRM ${combined === 1 ? 'record' : 'records'} combined, ${copied} copied`\n return refusals\n ? `${clause}; ${refusals} declined ${refusals === 1 ? 'consent' : 'consents'} copied`\n : clause\n}\n"],"names":["summarizeConsentGroupChange","counts","combined","copied","refusals","clause"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;CAOC,GACD,OAAO,SAASA,4BACdC,MAAwC;QAEvBA,kBACFA,gBACEA;IAFjB,MAAMC,YAAWD,mBAAAA,MAAM,CAAC,WAAW,YAAlBA,mBAAsB;IACvC,MAAME,UAASF,iBAAAA,MAAM,CAAC,SAAS,YAAhBA,iBAAoB;IACnC,MAAMG,YAAWH,mBAAAA,MAAM,CAAC,WAAW,YAAlBA,mBAAsB;IACvC,MAAMI,SAAS,GAAGH,SAAS,KAAK,EAAEA,aAAa,IAAI,WAAW,UAAU,WAAW,EAAEC,OAAO,OAAO,CAAC;IACpG,OAAOC,WACH,GAAGC,OAAO,EAAE,EAAED,SAAS,UAAU,EAAEA,aAAa,IAAI,YAAY,WAAW,OAAO,CAAC,GACnFC;AACN"}
|
|
@@ -154,7 +154,7 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
154
154
|
source
|
|
155
155
|
}, request.marketingConsent ? {
|
|
156
156
|
marketingConsent: true
|
|
157
|
-
} : {})
|
|
157
|
+
} : {}, disclosureOf(request))
|
|
158
158
|
}, campaignTouchOf(request.detail).campaignTouch ? {
|
|
159
159
|
touch: campaignTouchOf(request.detail).campaignTouch
|
|
160
160
|
} : {}));
|
|
@@ -200,7 +200,7 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
200
200
|
} : {}, entryPointOf(request.detail))
|
|
201
201
|
}, campaignTouchOf(request.detail), request.marketingConsent === undefined ? {} : {
|
|
202
202
|
marketingConsent: request.marketingConsent
|
|
203
|
-
}, ((_request_tags = request.tags) == null ? void 0 : _request_tags.length) ? {
|
|
203
|
+
}, disclosureOf(request), ((_request_tags = request.tags) == null ? void 0 : _request_tags.length) ? {
|
|
204
204
|
tags: request.tags
|
|
205
205
|
} : {}, ((_request_campaignIds = request.campaignIds) == null ? void 0 : _request_campaignIds.length) ? {
|
|
206
206
|
campaignIds: request.campaignIds
|
|
@@ -240,6 +240,20 @@ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
|
|
|
240
240
|
};
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* THE DISCLOSURE the capture surface rendered, handed to whichever record the
|
|
245
|
+
* capture lands on (AGL-3320).
|
|
246
|
+
*
|
|
247
|
+
* Passed through unread: both writers resolve the site's group themselves and
|
|
248
|
+
* pool the opt-in only when this is its current key, so a lead and a contact
|
|
249
|
+
* written by one capture record the same sites. A string or nothing, since
|
|
250
|
+
* the writers compare it exactly and anything else could only fail to match.
|
|
251
|
+
*/ function disclosureOf(request) {
|
|
252
|
+
const key = request.disclosedConsentGroup;
|
|
253
|
+
return typeof key === 'string' && key ? {
|
|
254
|
+
disclosedConsentGroup: key
|
|
255
|
+
} : {};
|
|
256
|
+
}
|
|
243
257
|
/**
|
|
244
258
|
* THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).
|
|
245
259
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","stored","name","marketingConsent","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAMD,SACEA,qBAAqB,QAEhB,kCAAiC;AACxC,SAEEC,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,kBAAkB,QAAQ,6CAA4C;AAC/E,SAASC,aAAa,QAAQ,wCAAuC;AACrE,SACEC,WAAW,EACXC,kBAAkB,EAClBC,aAAa,EACbC,wBAAwB,EACxBC,eAAe,QAEV,2BAA0B;AAEjC,SAASC,0BAA0B,QAAQ,yBAAqB;AAEhE;;;;;;;;;;;;;;;;;;;;;CAqBC,GACD;;;;;;;;;;;;;;;;;;;;;;;;;;;CA2BC,GACD,OAAO,eAAeC,qBACpBC,OAAoC;QAEpBA;IAAhB,MAAMC,WAAUD,mBAAAA,QAAQC,OAAO,YAAfD,mBAAmB;IACnC,qEAAqE;IACrE,0DAA0D;IAC1D,IAAI,CAACX,sBAAsBW,QAAQE,QAAQ,CAACC,KAAK,GAAG,OAAOC;IAC3D,IAAI;QACF,IAAIH,YAAY,QAAQ;YACtB,IAAI,CAAE,MAAMI,cAAcL,UAAW,OAAO,MAAMM,SAASN;QAC7D,OAAO,IAAIC,YAAY,SAAS;YAC9B,IAAI,MAAMM,YAAYP,UAAU,OAAO,MAAMM,SAASN;QACxD;QACA,MAAMQ,UAAU,MAAMC,iBAAiBT;QACvC,IAAIQ,QAAQE,EAAE,IAAIF,QAAQG,MAAM,KAAK,aAAaV,YAAY,gBAAgB;YAC5E,MAAMH,2BAA2B;gBAC/Bc,QAAQZ,QAAQY,MAAM;gBACtBT,OAAOH,QAAQE,QAAQ,CAACC,KAAK;gBAC7BU,WAAWL,QAAQK,SAAS;gBAC5BC,IAAId,QAAQe,WAAW,CAACC,MAAM,KAAK,UAAU,aAAa;YAC5D;QACF;QACA,OAAOR;IACT,EAAE,OAAOS,OAAO;QACdC,QAAQD,KAAK,CAAC,8BAA8BA;QAC5C,OAAO;YACLP,IAAI;YACJS,QAAQ;YACRF,OAAO;QACT;IACF;AACF;AAEA,gEAAgE,GAChE,SAASb;IACP,OAAO;QAAEM,IAAI;QAAOS,QAAQ;QAAiBF,OAAOG,YAAY;IAAiB;AACnF;AAEA;;;;;;CAMC,GACD,eAAef,cAAcL,OAAoC;IAC/D,MAAMqB,WAAW,MAAMzB,yBAAyBI,QAAQY,MAAM,EAAE;IAChE,OAAO,AAAC,MAAMlB,mBAAmB2B,UAAUrB,QAAQE,QAAQ,CAACC,KAAK,MAAO;AAC1E;AAEA,wFAAwF,GACxF,eAAeI,YAAYP,OAAoC;QAU/CsB;IATd,MAAMC,MAAMjC,UAAUU,QAAQE,QAAQ,CAACC,KAAK;IAC5C;;;;;GAKC,GACD,MAAMmB,WAAW,MAAMzB,gBAAgBG,QAAQY,MAAM,EAAEW;IACvD,IAAI,CAACD,UAAU,OAAO;IACtB,MAAME,QAAQF,iBAAAA,SAASG,IAAI,cAAbH,iBAAmB,CAAC;IAClC,OAAO,CAACE,KAAKE,kBAAkB,IAAItC,cAAcoC;AACnD;AAEA;;;;;CAKC,GACD,eAAelB,SAASN,OAAoC;IAC1D,MAAMG,QAAQd,sBAAsBW,QAAQE,QAAQ,CAACC,KAAK;IAC1D,MAAMoB,MAAMjC,UAAUa;IACtB,MAAMwB,UAAUhC,cAAciC,GAAG,GAAGC,SAAS,GAAGC,UAAU,CAAC,SAASC,GAAG,CAAC/B,QAAQY,MAAM;IACtF,0EAA0E;IAC1E,8EAA8E;IAC9E,MAAMoB,UAAU,CAAE,MAAMnC,gBAAgBG,QAAQY,MAAM,EAAEW;IACxD,MAAM,EAAEU,MAAM,EAAE,GAAGC,aAAalC,QAAQmC,MAAM;IAC9C,MAAMnB,SACJhB,QAAQe,WAAW,CAACC,MAAM,KAAK,UAAUiB,SACrC,CAAC,KAAK,EAAEA,QAAQ,GAChBjC,QAAQe,WAAW,CAACC,MAAM;IAChC,MAAMoB,SAAS,MAAM3C,YAAY;QAC/BkC;QACAf,QAAQZ,QAAQY,MAAM;QACtBY,MAAM;YACJrB;WACIH,QAAQE,QAAQ,CAACmC,IAAI,GAAG;YAAEA,MAAMrC,QAAQE,QAAQ,CAACmC,IAAI;QAAC,IAAI,CAAC;YAC/DrB;WACIhB,QAAQsC,gBAAgB,GAAG;YAAEA,kBAAkB;QAAK,IAAI,CAAC;OAE3DC,gBAAgBvC,QAAQmC,MAAM,EAAEK,aAAa,GAC7C;QAAEC,OAAOF,gBAAgBvC,QAAQmC,MAAM,EAAEK,aAAa;IAAC,IACvD,CAAC;IAEP,IAAI,CAACJ,QAAQ;QACX,OAAO;YACL1B,IAAI;YACJS,QAAQ;YACRF,OAAO;QACT;IACF;IACA,IAAIe,SAAS;QACX,MAAMxC,cAAcQ,QAAQY,MAAM,EAAE,QAAQ;YAC1CT;YACAa;YACA0B,QAAQnB;WACJvB,QAAQE,QAAQ,CAACmC,IAAI,GAAG;YAAEA,MAAMrC,QAAQE,QAAQ,CAACmC,IAAI;QAAC,IAAI,CAAC;IAEnE;IACA,OAAO;QAAE3B,IAAI;QAAMC,QAAQ;QAAQ+B,QAAQnB;QAAKS;IAAQ;AAC1D;AAEA,6EAA6E,GAC7E,eAAevB,iBACbT,OAAoC;IAEpC,IAAI;YAoBIA,eACAA;QApBN,MAAMQ,UAAU,MAAMjB,mBAAmB;YACvCqB,QAAQZ,QAAQY,MAAM;YACtBT,OAAOH,QAAQE,QAAQ,CAACC,KAAK;WACzBH,QAAQE,QAAQ,CAACmC,IAAI,GAAG;YAAEA,MAAMrC,QAAQE,QAAQ,CAACmC,IAAI;QAAC,IAAI,CAAC;YAC/DrB,QAAQ2B,gBAAgB3C,QAAQe,WAAW,CAACC,MAAM;YAClDD,aAAa,aACPf,QAAQe,WAAW,CAAC6B,IAAI,KAAKC,YAC7B,CAAC,IACD;gBAAED,MAAM5C,QAAQe,WAAW,CAAC6B,IAAI;YAAC,GACjC5C,QAAQe,WAAW,CAAC+B,KAAK,GAAG;gBAAEA,OAAO9C,QAAQe,WAAW,CAAC+B,KAAK;YAAC,IAAI,CAAC,GACpE9C,QAAQe,WAAW,CAACgC,OAAO,GAC3B;gBAAEA,SAAS/C,QAAQe,WAAW,CAACgC,OAAO;YAAC,IACvC,CAAC,GACFb,aAAalC,QAAQmC,MAAM;WAE7BI,gBAAgBvC,QAAQmC,MAAM,GAC7BnC,QAAQsC,gBAAgB,KAAKO,YAC7B,CAAC,IACD;YAAEP,kBAAkBtC,QAAQsC,gBAAgB;QAAC,GAC7CtC,EAAAA,gBAAAA,QAAQgD,IAAI,qBAAZhD,cAAciD,MAAM,IAAG;YAAED,MAAMhD,QAAQgD,IAAI;QAAC,IAAI,CAAC,GACjDhD,EAAAA,uBAAAA,QAAQkD,WAAW,qBAAnBlD,qBAAqBiD,MAAM,IAAG;YAAEC,aAAalD,QAAQkD,WAAW;QAAC,IAAI,CAAC,GACtElD,QAAQmD,aAAa,KAAKN,YAC1B,CAAC,IACD;YAAEM,eAAenD,QAAQmD,aAAa;QAAC,GACvCnD,QAAQoD,gBAAgB,GACxB;YAAEA,kBAAkBpD,QAAQoD,gBAAgB;QAAC,IAC7C,CAAC,GACDpD,QAAQqD,cAAc,GACtB;YAAEC,uBAAuBtD,QAAQqD,cAAc;QAAU,IACzD,CAAC,GACDrD,QAAQuD,OAAO,GAAG;YAAEC,OAAOxD,QAAQuD,OAAO;QAAU,IAAI,CAAC;QAE/D,IAAI,aAAa/C,SAAS;YACxB,OAAO;gBAAEE,IAAI;gBAAOS,QAAQX,QAAQiD,OAAO;gBAAExC,OAAOG,YAAYZ,QAAQiD,OAAO;YAAE;QACnF;QACA,OAAO;YACL/C,IAAI;YACJC,QAAQ;YACRE,WAAWL,QAAQK,SAAS;YAC5BmB,SAASxB,QAAQwB,OAAO;QAC1B;IACF,EAAE,OAAOf,OAAO;QACd;;;;;KAKC,GACDC,QAAQD,KAAK,CAAC,8BAA8BA;QAC5C,OAAO;YACLP,IAAI;YACJS,QAAQ;YACRF,OAAO;QACT;IACF;AACF;AAEA;;;;;;;;;;;;;;CAcC,GACD,SAASiB,aACPC,MAAqD;IAErD,MAAMuB,OAAO,CAACC,QACZ,OAAOA,UAAU,YAAYA,MAAMC,IAAI,KAAKD,QAAQd;IACtD,MAAMZ,SAASyB,KAAKvB,0BAAAA,MAAQ,CAAC,SAAS;IACtC,MAAM0B,OAAOH,KAAKvB,0BAAAA,MAAQ,CAAC,OAAO;IAClC,OAAO,aACDF,SAAS;QAAEA;IAAO,IAAI,CAAC,GACvB4B,OAAO;QAAEA;IAAK,IAAI,CAAC;AAE3B;AAEA;;;;;;;;;;;;;;;;CAgBC,GACD,SAAStB,gBACPJ,MAAqD;IAErD,MAAMM,QAAQN,0BAAAA,MAAQ,CAAC,gBAAgB;IACvC,OAAOM,QAAQ;QAAED,eAAeC;IAA+B,IAAI,CAAC;AACtE;AAEA;;;;;;;;;;;;;;CAcC,GACD,OAAO,SAASE,gBAAgB3B,MAAc;IAC5C,MAAM8C,OAAOC,OAAO/C,iBAAAA,SAAU,IAAI4C,IAAI;IACtC,IAAI,CAACI,OAAOC,MAAM,CAAC9E,uBAAuB2E,OAAO;QAC/C,sEAAsE;QACtE,2DAA2D;QAC3D5C,QAAQgD,IAAI,CACV,CAAC,6BAA6B,EAAEJ,KAAK,kCAAkC,CAAC,GACtE;IAEN;IACA,OAAOA;AACT;AAEA,oEAAoE,GACpE,SAAS1C,YAAYD,MAAqD;IACxE,OAAQA;QACN,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT,KAAK;YACH,OAAO;QACT;YACE,OAAO;IACX;AACF"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLead,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await fileLead(request)\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await fileLead(request)\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n by: request.interaction.source === 'order' ? 'purchase' : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? `form:${formId}`\n : request.interaction.source\n const stored = await addHostLead({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n },\n ...(campaignTouchOf(request.detail).campaignTouch\n ? { touch: campaignTouchOf(request.detail).campaignTouch }\n : {}),\n })\n if (!stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n await emitHostEvent(request.hostId, 'lead', {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n })\n }\n return { ok: true, record: 'lead', leadId: key, created }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...campaignTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...(request.campaignIds?.length ? { campaignIds: request.campaignIds } : {}),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved through the\n * allowlist by the silo; `campaignIds` is which campaigns the merchant filed\n * the capture SURFACE under, which is true of everybody who fills that form\n * in. Folding them would credit a campaign for a visitor who typed the\n * address.\n *\n * Passed through as the silo resolved it, unread: the touch's shape belongs\n * to whatever resolves it, and re-validating it here would be a second copy\n * of a rule that has already run. Only its presence is decided here, because\n * `null` and absent mean the same thing to the writer and a caller should\n * not have to know which one it sends.\n */\nfunction campaignTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'campaignTouch'> {\n const touch = detail?.['campaignTouch']\n return touch ? { campaignTouch: touch as ResolvedCampaignTouch } : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","emitHostEvent","addHostLead","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","convertOpenLeadOntoContact","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","fileLead","openLeadFor","verdict","captureOnContact","ok","record","hostId","contactId","by","interaction","source","error","console","reason","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","stored","name","marketingConsent","disclosureOf","campaignTouchOf","campaignTouch","touch","leadId","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","refused","disclosedConsentGroup","text","value","trim","path","word","String","Object","hasOwn","warn"],"mappings":"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"}
|
|
@@ -0,0 +1,81 @@
|
|
|
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 { ConsentGroupChangePlan } from '@aglyn/aglyn/app-utils/consent-group-change';
|
|
18
|
+
import type { ConsentGroupChangeParticipant } from '@aglyn/aglyn/plugin-manager/plugin-consent-group-change';
|
|
19
|
+
/** Contacts one page of the refusal carry reads. */
|
|
20
|
+
export declare const REFUSAL_PAGE = 300;
|
|
21
|
+
/** Contacts one page of the holder pass reads. */
|
|
22
|
+
export declare const HOLDER_PAGE = 200;
|
|
23
|
+
/**
|
|
24
|
+
* The most records a preview reads to answer a count no aggregation can:
|
|
25
|
+
* past it, the line says so with a `null` count rather than guess.
|
|
26
|
+
*/
|
|
27
|
+
export declare const PREVIEW_SCAN_LIMIT = 5000;
|
|
28
|
+
/**
|
|
29
|
+
* The figures a facet sums, and the instants that go with them — what
|
|
30
|
+
* `mergeContactFacet` adds up or takes the earliest or latest of. A split
|
|
31
|
+
* cannot divide one person's purchases between two holders, so a copy
|
|
32
|
+
* leaves them behind.
|
|
33
|
+
*/
|
|
34
|
+
export declare const HOLDER_FIGURE_FIELDS: readonly ["ltvCents", "ordersCount", "refundedCents", "refundedOrdersCount", "firstPurchaseAtMs", "lastPurchaseAtMs", "lastRefundAtMs", "lastEmailEngagementAtMs"];
|
|
35
|
+
/** The stamp a split's copy carries: `{ [sourceKey]: changeId }`. */
|
|
36
|
+
export declare const COPIED_BY_CHANGE_FIELD = "copiedByChange";
|
|
37
|
+
type Doc = Record<string, unknown>;
|
|
38
|
+
/** What one contact's holder records become, as a dotted `update()` patch. */
|
|
39
|
+
export interface HolderPatch {
|
|
40
|
+
patch: Doc;
|
|
41
|
+
combined: number;
|
|
42
|
+
copied: number;
|
|
43
|
+
figuresDropped: number;
|
|
44
|
+
/** Each company whose contacts count moves, by how much. */
|
|
45
|
+
companyCounts: Array<{
|
|
46
|
+
companyId: string;
|
|
47
|
+
delta: 1 | -1;
|
|
48
|
+
}>;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The holder records of one contact after the plan's flows — or `null` when
|
|
52
|
+
* nothing moves.
|
|
53
|
+
*
|
|
54
|
+
* `stragglersOnly` is the sweep: only the flows that delete their source
|
|
55
|
+
* (MOVE, and a SPLIT whose key dissolves) run, because only those can have
|
|
56
|
+
* left anything behind that a later writer recreated. A key that survived a
|
|
57
|
+
* split is a live key after the flip, and its new writes belong to it.
|
|
58
|
+
*/
|
|
59
|
+
export declare function planHolderRecords(contact: Doc, plan: Pick<ConsentGroupChangePlan, 'flows'>, changeId: string, options?: {
|
|
60
|
+
stragglersOnly?: boolean;
|
|
61
|
+
}): HolderPatch | null;
|
|
62
|
+
/**
|
|
63
|
+
* The refusal `from` holds, carried onto `to` — or `null` when `to` already
|
|
64
|
+
* refuses or `from` does not.
|
|
65
|
+
*/
|
|
66
|
+
export declare function planRefusalCarry(contact: Doc, carry: {
|
|
67
|
+
toHostId: string;
|
|
68
|
+
fromHostId: string;
|
|
69
|
+
}, changeId: string): Doc | null;
|
|
70
|
+
export interface ConsentGroupParticipantOptions {
|
|
71
|
+
firestore?: () => FirebaseFirestore.Firestore;
|
|
72
|
+
now?: () => number;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The CRM's participant. `firestore` and `now` are injectable for the specs;
|
|
76
|
+
* the defaults are the Admin SDK's and the wall clock.
|
|
77
|
+
*/
|
|
78
|
+
export declare function createConsentGroupParticipant(options?: ConsentGroupParticipantOptions): ConsentGroupChangeParticipant;
|
|
79
|
+
/** The participant the CRM registers, on the Admin SDK and the wall clock. */
|
|
80
|
+
export declare const consentGroupParticipant: ConsentGroupChangeParticipant;
|
|
81
|
+
export {};
|