@aglyn/plugins-crm 1.0.0-beta.152 → 1.0.0-beta.153

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +13 -13
  2. package/src/lib/components/lead-convert-dialog.js +4 -2
  3. package/src/lib/components/lead-convert-dialog.js.map +1 -1
  4. package/src/lib/components/lead-history-card.js +3 -0
  5. package/src/lib/components/lead-history-card.js.map +1 -1
  6. package/src/lib/components/lead-properties-card.js +209 -1
  7. package/src/lib/components/lead-properties-card.js.map +1 -1
  8. package/src/lib/components/lead-surfaces-note.d.ts +7 -3
  9. package/src/lib/components/lead-surfaces-note.js +6 -2
  10. package/src/lib/components/lead-surfaces-note.js.map +1 -1
  11. package/src/lib/components/leads-section.js +114 -2
  12. package/src/lib/components/leads-section.js.map +1 -1
  13. package/src/lib/components/new-lead-drawer.d.ts +56 -0
  14. package/src/lib/components/new-lead-drawer.js +327 -0
  15. package/src/lib/components/new-lead-drawer.js.map +1 -0
  16. package/src/lib/components/use-crm-api.d.ts +1 -1
  17. package/src/lib/components/use-crm-api.js.map +1 -1
  18. package/src/lib/model/crm-lead-import.d.ts +19 -3
  19. package/src/lib/model/crm-lead-import.js +159 -6
  20. package/src/lib/model/crm-lead-import.js.map +1 -1
  21. package/src/lib/model/lead-company-suggestion.d.ts +18 -3
  22. package/src/lib/model/lead-company-suggestion.js +41 -11
  23. package/src/lib/model/lead-company-suggestion.js.map +1 -1
  24. package/src/lib/server/capture-contact.d.ts +28 -0
  25. package/src/lib/server/capture-contact.js +139 -0
  26. package/src/lib/server/capture-contact.js.map +1 -1
  27. package/src/lib/server/convert-open-lead.d.ts +32 -0
  28. package/src/lib/server/convert-open-lead.js +91 -0
  29. package/src/lib/server/convert-open-lead.js.map +1 -0
  30. package/src/lib/server/lead-create.d.ts +76 -0
  31. package/src/lib/server/lead-create.js +227 -0
  32. package/src/lib/server/lead-create.js.map +1 -0
  33. package/src/lib/server/leads-import.js +7 -2
  34. package/src/lib/server/leads-import.js.map +1 -1
  35. package/src/lib/server/record-timeline.js +4 -2
  36. package/src/lib/server/record-timeline.js.map +1 -1
  37. package/src/lib/server.js +3 -0
  38. package/src/lib/server.js.map +1 -1
@@ -38,6 +38,34 @@ import { type ContactSource } from '@aglyn/aglyn/app-utils/contacts';
38
38
  * is RETURNED and costs the silo nothing. A throw here would lose an order
39
39
  * for a contact that could not be filed.
40
40
  */
41
+ /**
42
+ * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided
43
+ * here because the CRM is the plugin that models both records.
44
+ *
45
+ * One person is one record: a LEAD until somebody qualifies them, a CONTACT
46
+ * after. Every door used to write both — a lead-routed form filed a lead
47
+ * AND a contact at stage Lead — so one person sat in two lists and the Leads
48
+ * queue was never the whole story. Now a door says what kind of surface it
49
+ * is (`request.surface`) and this decides:
50
+ *
51
+ * - a LEAD surface files a lead, unless the workspace already holds the
52
+ * address as a contact — a customer who books a demo is a customer's
53
+ * interaction, not a new lead — in which case the capture lands on the
54
+ * contact and no lead is filed;
55
+ * - a RELATIONSHIP (a member account, a purchase) makes the contact, and
56
+ * an open lead the site held for the address is stamped converted onto
57
+ * it, so nobody keeps working a lead who already joined or bought;
58
+ * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead
59
+ * when the site holds one — its consent and its history stay on the one
60
+ * record the rep is working — and on the contact otherwise.
61
+ *
62
+ * A lead is filed through `addHostLead`, the one writer of the leads silo,
63
+ * so it is keyed on the person, counted against the ceiling and carries the
64
+ * campaign touch the door resolved. A NEW lead announces itself with the
65
+ * `lead` host event, which is what a "new lead" automation listens for; a
66
+ * repeat capture on a lead the site already held announces nothing, the
67
+ * way a repeat visit by a contact is an interaction and not a creation.
68
+ */
41
69
  export declare function captureContactForCrm(request: PluginContactCaptureRequest): Promise<PluginContactCaptured>;
42
70
  /**
43
71
  * The silo's word for its door, as a source the CRM stores.
@@ -15,7 +15,11 @@
15
15
  * limitations under the License.
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { CONTACT_SOURCE_LABELS } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
18
19
  import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
20
+ import { emitHostEvent } from "@aglyn/tenant-runtime/emit-host-event";
21
+ import { addHostLead, findContactByEmail, firebaseAdmin, orgDataCollectionForHost } from "@aglyn/tenant-data-admin";
22
+ import { convertOpenLeadOntoContact } from "./convert-open-lead.js";
19
23
  /**
20
24
  * The CRM answering the platform's contact-capture contract (AGL-3080).
21
25
  *
@@ -37,7 +41,141 @@ import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
37
41
  * recording — the submission was accepted, the order was paid — so a refusal
38
42
  * is RETURNED and costs the silo nothing. A throw here would lose an order
39
43
  * for a contact that could not be filed.
44
+ */ /**
45
+ * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided
46
+ * here because the CRM is the plugin that models both records.
47
+ *
48
+ * One person is one record: a LEAD until somebody qualifies them, a CONTACT
49
+ * after. Every door used to write both — a lead-routed form filed a lead
50
+ * AND a contact at stage Lead — so one person sat in two lists and the Leads
51
+ * queue was never the whole story. Now a door says what kind of surface it
52
+ * is (`request.surface`) and this decides:
53
+ *
54
+ * - a LEAD surface files a lead, unless the workspace already holds the
55
+ * address as a contact — a customer who books a demo is a customer's
56
+ * interaction, not a new lead — in which case the capture lands on the
57
+ * contact and no lead is filed;
58
+ * - a RELATIONSHIP (a member account, a purchase) makes the contact, and
59
+ * an open lead the site held for the address is stamped converted onto
60
+ * it, so nobody keeps working a lead who already joined or bought;
61
+ * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead
62
+ * when the site holds one — its consent and its history stay on the one
63
+ * record the rep is working — and on the contact otherwise.
64
+ *
65
+ * A lead is filed through `addHostLead`, the one writer of the leads silo,
66
+ * so it is keyed on the person, counted against the ceiling and carries the
67
+ * campaign touch the door resolved. A NEW lead announces itself with the
68
+ * `lead` host event, which is what a "new lead" automation listens for; a
69
+ * repeat capture on a lead the site already held announces nothing, the
70
+ * way a repeat visit by a contact is an interaction and not a creation.
40
71
  */ export async function captureContactForCrm(request) {
72
+ var _request_surface;
73
+ const surface = (_request_surface = request.surface) != null ? _request_surface : 'touch';
74
+ // The one refusal every surface shares, answered before any read: an
75
+ // address nothing can key is a person nothing can record.
76
+ if (!normalizeContactEmail(request.identity.email)) return refusedEmail();
77
+ try {
78
+ if (surface === 'lead') {
79
+ if (!await heldAsContact(request)) return await fileLead(request);
80
+ } else if (surface === 'touch') {
81
+ if (await openLeadFor(request)) return await fileLead(request);
82
+ }
83
+ const verdict = await captureOnContact(request);
84
+ if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {
85
+ await convertOpenLeadOntoContact({
86
+ hostId: request.hostId,
87
+ email: request.identity.email,
88
+ contactId: verdict.contactId,
89
+ by: request.interaction.source === 'order' ? 'purchase' : 'signup'
90
+ });
91
+ }
92
+ return verdict;
93
+ } catch (error) {
94
+ console.error('crm contact capture failed', error);
95
+ return {
96
+ ok: false,
97
+ reason: 'error',
98
+ error: 'The contact could not be recorded. Nothing else was affected.'
99
+ };
100
+ }
101
+ }
102
+ /** The refusal every door gets for an address nothing can key. */ function refusedEmail() {
103
+ return {
104
+ ok: false,
105
+ reason: 'invalid-email',
106
+ error: refusalText('invalid-email')
107
+ };
108
+ }
109
+ /**
110
+ * Whether the workspace already holds the address as a contact — the
111
+ * address index first, then the query, the same lookup every dedupe uses.
112
+ * ORG-wide, not scoped to the capturing site: the contact door dedupes
113
+ * across every site in the org, so a person any site holds as a contact
114
+ * is a contact, whichever site met them this time.
115
+ */ async function heldAsContact(request) {
116
+ const contacts = await orgDataCollectionForHost(request.hostId, 'contacts');
117
+ return await findContactByEmail(contacts, request.identity.email) !== null;
118
+ }
119
+ /** Whether the site holds an OPEN lead for the address — neither converted nor closed. */ async function openLeadFor(request) {
120
+ var _snapshot_data;
121
+ const key = personKey(request.identity.email);
122
+ const snapshot = await firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId).collection('leads').doc(key).get();
123
+ if (!snapshot.exists) return false;
124
+ const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
125
+ return !lead.convertedContactId && isCrmLeadOpen(lead);
126
+ }
127
+ /**
128
+ * The capture as a LEAD. The source string is the lead silo's own
129
+ * vocabulary — `form:{formId}` for a form, the door's word otherwise — so
130
+ * a lead's provenance survives the form being renamed, as the submission's
131
+ * does.
132
+ */ async function fileLead(request) {
133
+ const email = normalizeContactEmail(request.identity.email);
134
+ const key = personKey(email);
135
+ const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId);
136
+ const leadRef = hostRef.collection('leads').doc(key);
137
+ const created = !(await leadRef.get()).exists;
138
+ const { formId } = entryPointOf(request.detail);
139
+ const source = request.interaction.source === 'form' && formId ? `form:${formId}` : request.interaction.source;
140
+ const stored = await addHostLead(_extends({
141
+ hostRef,
142
+ hostId: request.hostId,
143
+ lead: _extends({
144
+ email
145
+ }, request.identity.name ? {
146
+ name: request.identity.name
147
+ } : {}, {
148
+ source
149
+ }, request.marketingConsent ? {
150
+ marketingConsent: true
151
+ } : {})
152
+ }, campaignTouchOf(request.detail).campaignTouch ? {
153
+ touch: campaignTouchOf(request.detail).campaignTouch
154
+ } : {}));
155
+ if (!stored) {
156
+ return {
157
+ ok: false,
158
+ reason: 'band',
159
+ error: 'This site is at the number of leads it may hold, so the lead was not recorded.'
160
+ };
161
+ }
162
+ if (created) {
163
+ await emitHostEvent(request.hostId, 'lead', _extends({
164
+ email,
165
+ source,
166
+ leadId: key
167
+ }, request.identity.name ? {
168
+ name: request.identity.name
169
+ } : {}));
170
+ }
171
+ return {
172
+ ok: true,
173
+ record: 'lead',
174
+ leadId: key,
175
+ created
176
+ };
177
+ }
178
+ /** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
41
179
  try {
42
180
  var _request_tags, _request_campaignIds;
43
181
  const verdict = await captureHostContact(_extends({
@@ -78,6 +216,7 @@ import { captureHostContact } from "@aglyn/tenant-runtime/capture-host-contact";
78
216
  }
79
217
  return {
80
218
  ok: true,
219
+ record: 'contact',
81
220
  contactId: verdict.contactId,
82
221
  created: verdict.created
83
222
  };
@@ -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 { captureHostContact } from '@aglyn/tenant-runtime/capture-host-contact'\nimport type { UpsertHostContactOptions } from '@aglyn/tenant-data-admin'\nimport type { ResolvedCampaignTouch } from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\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 */\nexport async function captureContactForCrm(\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 { ok: true, contactId: verdict.contactId, created: verdict.created }\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","captureHostContact","captureContactForCrm","request","verdict","hostId","email","identity","name","source","contactSourceOf","interaction","atMs","undefined","refId","summary","entryPointOf","detail","campaignTouchOf","marketingConsent","tags","length","campaignIds","purchaseCents","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","ok","reason","refused","error","refusalText","contactId","created","console","text","value","trim","formId","path","touch","campaignTouch","word","String","Object","hasOwn","warn"],"mappings":"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"}
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 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 const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(request.hostId)\n .collection('leads')\n .doc(key)\n .get()\n if (!snapshot.exists) 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 const leadRef = hostRef.collection('leads').doc(key)\n const created = !(await leadRef.get()).exists\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","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","app","firestore","collection","doc","get","exists","lead","data","convertedContactId","hostRef","leadRef","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":"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"}
@@ -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
+ /** Which door closed the lead, recorded on it beside the conversion stamp. */
18
+ export type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill';
19
+ export interface ConvertOpenLeadOntoContactInput {
20
+ hostId: string;
21
+ email: unknown;
22
+ /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */
23
+ contactId: string;
24
+ by: LeadAutoConvertedBy;
25
+ }
26
+ /**
27
+ * Stamp the site's open lead for `email` as converted onto `contactId`.
28
+ * `true` when a lead was stamped; `false` when the site holds none, holds
29
+ * one that is not open, or the write failed.
30
+ */
31
+ export declare function convertOpenLeadOntoContact(input: ConvertOpenLeadOntoContactInput): Promise<boolean>;
32
+ export default convertOpenLeadOntoContact;
@@ -0,0 +1,91 @@
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
+ * An open lead, closed as converted because the person became a
18
+ * relationship on their own (AGL-3232).
19
+ *
20
+ * Salesforce closes a lead when somebody converts it. Aglyn has a door that
21
+ * makes a person a contact without anybody deciding anything — a member
22
+ * account — and a lead left open behind it is a rep chasing a member. So a
23
+ * relationship capture asks here, after the contact exists: the site's
24
+ * lead for the address, if it is still open, is stamped converted onto
25
+ * that contact, the way the convert dialog stamps one, with `convertedBy`
26
+ * saying which door did it. The CRM's own module, because a lead is the
27
+ * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it
28
+ * calls it, and the one-record backfill stamps the same shape by hand.
29
+ *
30
+ * Only an OPEN lead: one already converted names its contact and must keep
31
+ * naming it, and one closed as unqualified was a decision — a person the
32
+ * team judged not real who then buys something is a fact for the contact's
33
+ * timeline, not a reason to rewrite the team's verdict.
34
+ *
35
+ * Once stamped, the lead hands what was filed on it to the contact — its
36
+ * activities, its tasks, and every plugin's records about it — exactly as
37
+ * the convert dialog's conversion does (AGL-3233), so a lead closed by a
38
+ * purchase leaves the same timeline behind as one a rep converted.
39
+ *
40
+ * Never throws: the capture that made the contact has already happened,
41
+ * and a lead that could not be stamped is one somebody converts by hand.
42
+ */ import { isCrmLeadOpen, normalizeContactEmail, personKey } from "@aglyn/aglyn/server";
43
+ import { firebaseAdmin, getOrgForHost } from "@aglyn/tenant-data-admin";
44
+ import { handOffLeadRecords } from "@aglyn/tenant-runtime/hand-off-lead";
45
+ import { FieldValue } from "firebase-admin/firestore";
46
+ /**
47
+ * Stamp the site's open lead for `email` as converted onto `contactId`.
48
+ * `true` when a lead was stamped; `false` when the site holds none, holds
49
+ * one that is not open, or the write failed.
50
+ */ export async function convertOpenLeadOntoContact(input) {
51
+ const email = normalizeContactEmail(input.email);
52
+ const key = email ? personKey(email) : null;
53
+ if (!key || !input.contactId) return false;
54
+ try {
55
+ var _snapshot_data;
56
+ const firestore = firebaseAdmin.app().firestore();
57
+ const leadRef = firestore.collection('hosts').doc(input.hostId).collection('leads').doc(key);
58
+ const snapshot = await leadRef.get();
59
+ if (!snapshot.exists) return false;
60
+ const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
61
+ if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false;
62
+ await leadRef.set({
63
+ status: 'qualified',
64
+ convertedContactId: input.contactId,
65
+ convertedAtMs: Date.now(),
66
+ convertedBy: input.by,
67
+ updatedAt: FieldValue.serverTimestamp()
68
+ }, {
69
+ merge: true
70
+ });
71
+ const resolved = await getOrgForHost(input.hostId);
72
+ if (resolved) {
73
+ await handOffLeadRecords({
74
+ firestore,
75
+ orgId: resolved.orgId,
76
+ hostId: input.hostId,
77
+ leadId: key,
78
+ contactId: input.contactId,
79
+ email,
80
+ by: input.by
81
+ });
82
+ }
83
+ return true;
84
+ } catch (error) {
85
+ console.error('convertOpenLeadOntoContact failed', input.hostId, error);
86
+ return false;
87
+ }
88
+ }
89
+ export default convertOpenLeadOntoContact;
90
+
91
+ //# sourceMappingURL=convert-open-lead.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/convert-open-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * An open lead, closed as converted because the person became a\n * relationship on their own (AGL-3232).\n *\n * Salesforce closes a lead when somebody converts it. Aglyn has a door that\n * makes a person a contact without anybody deciding anything — a member\n * account — and a lead left open behind it is a rep chasing a member. So a\n * relationship capture asks here, after the contact exists: the site's\n * lead for the address, if it is still open, is stamped converted onto\n * that contact, the way the convert dialog stamps one, with `convertedBy`\n * saying which door did it. The CRM's own module, because a lead is the\n * CRM's record (docs/PACKAGES.md rule 3): the capture writer beside it\n * calls it, and the one-record backfill stamps the same shape by hand.\n *\n * Only an OPEN lead: one already converted names its contact and must keep\n * naming it, and one closed as unqualified was a decision — a person the\n * team judged not real who then buys something is a fact for the contact's\n * timeline, not a reason to rewrite the team's verdict.\n *\n * Once stamped, the lead hands what was filed on it to the contact — its\n * activities, its tasks, and every plugin's records about it — exactly as\n * the convert dialog's conversion does (AGL-3233), so a lead closed by a\n * purchase leaves the same timeline behind as one a rep converted.\n *\n * Never throws: the capture that made the contact has already happened,\n * and a lead that could not be stamped is one somebody converts by hand.\n */\n\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { firebaseAdmin, getOrgForHost } from '@aglyn/tenant-data-admin'\nimport { handOffLeadRecords } from '@aglyn/tenant-runtime/hand-off-lead'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** Which door closed the lead, recorded on it beside the conversion stamp. */\nexport type LeadAutoConvertedBy = 'signup' | 'purchase' | 'backfill'\n\nexport interface ConvertOpenLeadOntoContactInput {\n hostId: string\n email: unknown\n /** `orgs/{orgId}/contacts/{contactId}` — the relationship the lead became. */\n contactId: string\n by: LeadAutoConvertedBy\n}\n\n/**\n * Stamp the site's open lead for `email` as converted onto `contactId`.\n * `true` when a lead was stamped; `false` when the site holds none, holds\n * one that is not open, or the write failed.\n */\nexport async function convertOpenLeadOntoContact(\n input: ConvertOpenLeadOntoContactInput,\n): Promise<boolean> {\n const email = normalizeContactEmail(input.email)\n const key = email ? personKey(email) : null\n if (!key || !input.contactId) return false\n try {\n const firestore = firebaseAdmin.app().firestore()\n const leadRef = firestore\n .collection('hosts')\n .doc(input.hostId)\n .collection('leads')\n .doc(key)\n const snapshot = await leadRef.get()\n if (!snapshot.exists) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId || !isCrmLeadOpen(lead)) return false\n await leadRef.set(\n {\n status: 'qualified',\n convertedContactId: input.contactId,\n convertedAtMs: Date.now(),\n convertedBy: input.by,\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n const resolved = await getOrgForHost(input.hostId)\n if (resolved) {\n await handOffLeadRecords({\n firestore,\n orgId: resolved.orgId,\n hostId: input.hostId,\n leadId: key,\n contactId: input.contactId,\n email,\n by: input.by,\n })\n }\n return true\n } catch (error) {\n console.error('convertOpenLeadOntoContact failed', input.hostId, error)\n return false\n }\n}\n\nexport default convertOpenLeadOntoContact\n"],"names":["isCrmLeadOpen","normalizeContactEmail","personKey","firebaseAdmin","getOrgForHost","handOffLeadRecords","FieldValue","convertOpenLeadOntoContact","input","email","key","contactId","snapshot","firestore","app","leadRef","collection","doc","hostId","get","exists","lead","data","convertedContactId","set","status","convertedAtMs","Date","now","convertedBy","by","updatedAt","serverTimestamp","merge","resolved","orgId","leadId","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAEEA,aAAa,EACbC,qBAAqB,EACrBC,SAAS,QACJ,sBAAqB;AAC5B,SAASC,aAAa,EAAEC,aAAa,QAAQ,2BAA0B;AACvE,SAASC,kBAAkB,QAAQ,sCAAqC;AACxE,SAASC,UAAU,QAAQ,2BAA0B;AAarD;;;;CAIC,GACD,OAAO,eAAeC,2BACpBC,KAAsC;IAEtC,MAAMC,QAAQR,sBAAsBO,MAAMC,KAAK;IAC/C,MAAMC,MAAMD,QAAQP,UAAUO,SAAS;IACvC,IAAI,CAACC,OAAO,CAACF,MAAMG,SAAS,EAAE,OAAO;IACrC,IAAI;YASYC;QARd,MAAMC,YAAYV,cAAcW,GAAG,GAAGD,SAAS;QAC/C,MAAME,UAAUF,UACbG,UAAU,CAAC,SACXC,GAAG,CAACT,MAAMU,MAAM,EAChBF,UAAU,CAAC,SACXC,GAAG,CAACP;QACP,MAAME,WAAW,MAAMG,QAAQI,GAAG;QAClC,IAAI,CAACP,SAASQ,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAQT,iBAAAA,SAASU,IAAI,cAAbV,iBAAmB,CAAC;QAClC,IAAIS,KAAKE,kBAAkB,IAAI,CAACvB,cAAcqB,OAAO,OAAO;QAC5D,MAAMN,QAAQS,GAAG,CACf;YACEC,QAAQ;YACRF,oBAAoBf,MAAMG,SAAS;YACnCe,eAAeC,KAAKC,GAAG;YACvBC,aAAarB,MAAMsB,EAAE;YACrBC,WAAWzB,WAAW0B,eAAe;QACvC,GACA;YAAEC,OAAO;QAAK;QAEhB,MAAMC,WAAW,MAAM9B,cAAcI,MAAMU,MAAM;QACjD,IAAIgB,UAAU;YACZ,MAAM7B,mBAAmB;gBACvBQ;gBACAsB,OAAOD,SAASC,KAAK;gBACrBjB,QAAQV,MAAMU,MAAM;gBACpBkB,QAAQ1B;gBACRC,WAAWH,MAAMG,SAAS;gBAC1BF;gBACAqB,IAAItB,MAAMsB,EAAE;YACd;QACF;QACA,OAAO;IACT,EAAE,OAAOO,OAAO;QACdC,QAAQD,KAAK,CAAC,qCAAqC7B,MAAMU,MAAM,EAAEmB;QACjE,OAAO;IACT;AACF;AAEA,eAAe9B,2BAA0B"}
@@ -0,0 +1,76 @@
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
+ * `POST /api/crm/leads-create` — a lead typed in by hand (AGL-3231).
19
+ *
20
+ * Salesforce's New Lead: a person the team has heard of and has not yet
21
+ * qualified, entered with what is known about them — the address, the
22
+ * name, the company as text, a title, a phone, a website, an address, tags,
23
+ * where they came from — and nothing else brought into being. No contact,
24
+ * no company record: those are the conversion's to make, once the lead is
25
+ * real. Until then the lead is the whole record, which is what lets the
26
+ * Leads section be worked without a second row for every person in it.
27
+ *
28
+ * ## Through the one door
29
+ *
30
+ * `addHostLead` is the single writer of `hosts/{hostId}/leads` — the
31
+ * capture doors, the import and the REST create all reach it — and it is
32
+ * the writer here. That is what keys the lead on the person, so a second
33
+ * "new lead" for one address updates the first rather than filing twice;
34
+ * counts it against the platform ceiling inside the transaction that
35
+ * writes; and records NO marketing basis, because a person typed in by the
36
+ * team is not a person who ticked a box. The profile and the working state
37
+ * are the CRM's own annotations and land in one merge write after the
38
+ * door's, exactly as the import writes them.
39
+ *
40
+ * ## Who may call it
41
+ *
42
+ * The same two gates the convert route asks: `data.manage` in the org, and
43
+ * a role on THIS site — a lead is the site's record, so an org member scoped
44
+ * to a sibling site holds the key but not the host. Then the CRM suite gate,
45
+ * because a lead entered by hand is suite work: the capture doors that fill
46
+ * a Free workspace's leads do not come through here.
47
+ */
48
+ import { type CrmLeadProfilePatch, type CrmLeadStatus, type PluginApiHandler } from '@aglyn/aglyn/server';
49
+ /** The surface a hand-entered lead names, beside `signup`, `booking`, `form:{id}` and `import`. */
50
+ export declare const LEAD_MANUAL_SOURCE = "manual";
51
+ /** What the New lead drawer posts. */
52
+ export interface LeadCreateRequest {
53
+ hostId: string;
54
+ email: string;
55
+ name?: string;
56
+ company?: string;
57
+ jobTitle?: string;
58
+ phone?: string;
59
+ website?: string;
60
+ address?: Record<string, unknown> | null;
61
+ tags?: string[];
62
+ leadSource?: string;
63
+ status?: CrmLeadStatus;
64
+ ownerUid?: string;
65
+ notes?: string;
66
+ }
67
+ /** What the route answers on success. */
68
+ export interface LeadCreateResponse {
69
+ leadId: string;
70
+ /** False when the site already held a lead for the address — it was updated. */
71
+ created: boolean;
72
+ }
73
+ /** The lead's own fields as one write, `null` in the patch meaning a clear. */
74
+ export declare function leadProfileWrite(patch: CrmLeadProfilePatch): Record<string, unknown>;
75
+ export declare const leadCreateHandler: PluginApiHandler;
76
+ export default leadCreateHandler;