@aglyn/tenant-runtime 1.0.0-beta.217 → 1.0.0-beta.219

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.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/get-variables.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 * as Aglyn from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\n\n/**\n * Part of the per-host compose bundle every page render pays (AGL-1302):\n * variables, functions and workflows were three ~100-doc queries per\n * render. All three fail open, so a cache-layer failure degrades to the\n * uncached read rather than an empty map. 60s matches the page's own ISR\n * window; the publish path busts `tenant-data:{hostId}` on top.\n */\nconst HOST_BINDINGS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\nconst cachedBindingsRead = async <T>(\n name: string,\n hostId: string,\n read: () => Promise<Record<string, T>>,\n): Promise<Record<string, T>> => {\n try {\n return await withRenderCache({\n key: [name, hostId],\n revalidate: HOST_BINDINGS_TTL_SECONDS,\n tags: [tenantDataTag(hostId)],\n read,\n })\n } catch (error) {\n console.error(error)\n return read()\n }\n}\n\n/**\n * Fetches the host's variables keyed by doc id (AGL-185/194) for\n * `{{var:id}}` tokens; expression scopes read names off the values.\n * Fail-open: on error an empty map is returned and tokens render\n * literally/empty.\n */\nexport async function getVariables(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostVariable>> {\n return cachedBindingsRead('tenant-variables', options.hostId, () =>\n readVariables(options),\n )\n}\n\nasync function readVariables(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostVariable>> {\n const variables: Record<string, Aglyn.HostVariable> = {}\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(options.hostId)\n .collection('variables')\n .limit(100)\n .get()\n for (const docSnapshot of snapshot.docs) {\n const data = docSnapshot.data() as Aglyn.HostVariable\n if (data?.name && !((data as any).deletedAt)) {\n variables[docSnapshot.id] = data\n }\n }\n } catch (error) {\n console.error(error)\n }\n return variables\n}\n\n/**\n * Fetches the host's function definitions keyed by name for\n * `{{fn:name(...)}}` bindings (AGL-93). Fail-open like variables.\n */\nexport async function getFunctions(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostFunction>> {\n return cachedBindingsRead('tenant-functions', options.hostId, () =>\n readFunctions(options),\n )\n}\n\nasync function readFunctions(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostFunction>> {\n const functions: Record<string, Aglyn.HostFunction> = {}\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(options.hostId)\n .collection('functions')\n .limit(100)\n .get()\n for (const docSnapshot of snapshot.docs) {\n const data = docSnapshot.data() as Aglyn.HostFunction\n if (data?.name && !(data as any).deletedAt) {\n // Plain evaluator fields only: doc timestamps aren't\n // JSON-serializable and definitions embed into page props.\n const definition = {\n name: data.name,\n parameters: data.parameters ?? [],\n variables: data.variables ?? [],\n operations: data.operations ?? [],\n ...(data.returnValue && { returnValue: data.returnValue }),\n }\n // Keyed by doc id ({{fn:id(...)}}, AGL-185/194) AND name so\n // legacy workflow steps that stored functionName keep resolving\n // (AGL-261).\n functions[docSnapshot.id] = definition\n if (!functions[data.name]) functions[data.name] = definition\n }\n }\n } catch (error) {\n console.error(error)\n }\n return functions\n}\n\nexport default getVariables\n\n/**\n * Fetches the host's workflows keyed by name for computed variables and\n * function→workflow calls (AGL-129). Fail-open like variables.\n */\nexport async function getWorkflows(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostWorkflow>> {\n return cachedBindingsRead('tenant-workflows', options.hostId, () =>\n readWorkflows(options),\n )\n}\n\nasync function readWorkflows(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostWorkflow>> {\n const workflows: Record<string, Aglyn.HostWorkflow> = {}\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(options.hostId)\n .collection('workflows')\n .limit(100)\n .get()\n for (const docSnapshot of snapshot.docs) {\n const data = docSnapshot.data() as Aglyn.HostWorkflow\n if ((data as any).deletedAt) continue\n // Double-keyed by doc id AND name (AGL-261): id references are\n // rename-safe; legacy name references keep resolving.\n workflows[docSnapshot.id] = data\n if (data?.name) workflows[data.name] = data\n }\n } catch (error) {\n console.error(error)\n }\n return workflows\n}\n"],"names":["firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","HOST_BINDINGS_TTL_SECONDS","cachedBindingsRead","name","hostId","read","key","revalidate","tags","error","console","getVariables","options","readVariables","variables","snapshot","app","firestore","collection","doc","limit","get","docSnapshot","docs","data","deletedAt","id","getFunctions","readFunctions","functions","definition","parameters","operations","returnValue","getWorkflows","readWorkflows","workflows"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/get-variables.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 * as Aglyn from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\n\n/**\n * Part of the per-host compose bundle every page render pays (AGL-1302):\n * variables and functions are two ~100-doc queries per render. Both fail\n * open, so a cache-layer failure degrades to the uncached read rather than\n * an empty map. The publish path busts `tenant-data:{hostId}` on top.\n */\nconst HOST_BINDINGS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\nconst cachedBindingsRead = async <T>(\n name: string,\n hostId: string,\n read: () => Promise<Record<string, T>>,\n): Promise<Record<string, T>> => {\n try {\n return await withRenderCache({\n key: [name, hostId],\n revalidate: HOST_BINDINGS_TTL_SECONDS,\n tags: [tenantDataTag(hostId)],\n read,\n })\n } catch (error) {\n console.error(error)\n return read()\n }\n}\n\n/**\n * Fetches the host's variables keyed by doc id (AGL-185/194) for\n * `{{var:id}}` tokens; expression scopes read names off the values.\n * Fail-open: on error an empty map is returned and tokens render\n * literally/empty.\n */\nexport async function getVariables(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostVariable>> {\n return cachedBindingsRead('tenant-variables', options.hostId, () =>\n readVariables(options),\n )\n}\n\nasync function readVariables(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostVariable>> {\n const variables: Record<string, Aglyn.HostVariable> = {}\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(options.hostId)\n .collection('variables')\n .limit(100)\n .get()\n for (const docSnapshot of snapshot.docs) {\n const data = docSnapshot.data() as Aglyn.HostVariable\n if (data?.name && !((data as any).deletedAt)) {\n variables[docSnapshot.id] = data\n }\n }\n } catch (error) {\n console.error(error)\n }\n return variables\n}\n\n/**\n * Fetches the host's function definitions keyed by name for\n * `{{fn:name(...)}}` bindings (AGL-93). Fail-open like variables.\n */\nexport async function getFunctions(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostFunction>> {\n return cachedBindingsRead('tenant-functions', options.hostId, () =>\n readFunctions(options),\n )\n}\n\nasync function readFunctions(options: {\n hostId: string\n}): Promise<Record<string, Aglyn.HostFunction>> {\n const functions: Record<string, Aglyn.HostFunction> = {}\n try {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(options.hostId)\n .collection('functions')\n .limit(100)\n .get()\n for (const docSnapshot of snapshot.docs) {\n const data = docSnapshot.data() as Aglyn.HostFunction\n if (data?.name && !(data as any).deletedAt) {\n // Plain evaluator fields only: doc timestamps aren't\n // JSON-serializable and definitions embed into page props.\n const definition = {\n name: data.name,\n parameters: data.parameters ?? [],\n variables: data.variables ?? [],\n operations: data.operations ?? [],\n ...(data.returnValue && { returnValue: data.returnValue }),\n }\n // Keyed by doc id ({{fn:id(...)}}, AGL-185/194) AND name so\n // legacy workflow steps that stored functionName keep resolving\n // (AGL-261).\n functions[docSnapshot.id] = definition\n if (!functions[data.name]) functions[data.name] = definition\n }\n }\n } catch (error) {\n console.error(error)\n }\n return functions\n}\n\nexport default getVariables\n"],"names":["firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","HOST_BINDINGS_TTL_SECONDS","cachedBindingsRead","name","hostId","read","key","revalidate","tags","error","console","getVariables","options","readVariables","variables","snapshot","app","firestore","collection","doc","limit","get","docSnapshot","docs","data","deletedAt","id","getFunctions","readFunctions","functions","definition","parameters","operations","returnValue"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,aAAa,QAAQ,2BAA0B;AACxD,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAE9C;;;;;CAKC,GACD,MAAMC,4BAA4BH;AAElC,MAAMI,qBAAqB,OACzBC,MACAC,QACAC;IAEA,IAAI;QACF,OAAO,MAAML,gBAAgB;YAC3BM,KAAK;gBAACH;gBAAMC;aAAO;YACnBG,YAAYN;YACZO,MAAM;gBAACT,cAAcK;aAAQ;YAC7BC;QACF;IACF,EAAE,OAAOI,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAOJ;IACT;AACF;AAEA;;;;;CAKC,GACD,OAAO,eAAeM,aAAaC,OAElC;IACC,OAAOV,mBAAmB,oBAAoBU,QAAQR,MAAM,EAAE,IAC5DS,cAAcD;AAElB;AAEA,eAAeC,cAAcD,OAE5B;IACC,MAAME,YAAgD,CAAC;IACvD,IAAI;QACF,MAAMC,WAAW,MAAMlB,cACpBmB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACP,QAAQR,MAAM,EAClBc,UAAU,CAAC,aACXE,KAAK,CAAC,KACNC,GAAG;QACN,KAAK,MAAMC,eAAeP,SAASQ,IAAI,CAAE;YACvC,MAAMC,OAAOF,YAAYE,IAAI;YAC7B,IAAIA,CAAAA,wBAAAA,KAAMrB,IAAI,KAAI,CAAE,AAACqB,KAAaC,SAAS,EAAG;gBAC5CX,SAAS,CAACQ,YAAYI,EAAE,CAAC,GAAGF;YAC9B;QACF;IACF,EAAE,OAAOf,OAAO;QACdC,QAAQD,KAAK,CAACA;IAChB;IACA,OAAOK;AACT;AAEA;;;CAGC,GACD,OAAO,eAAea,aAAaf,OAElC;IACC,OAAOV,mBAAmB,oBAAoBU,QAAQR,MAAM,EAAE,IAC5DwB,cAAchB;AAElB;AAEA,eAAegB,cAAchB,OAE5B;IACC,MAAMiB,YAAgD,CAAC;IACvD,IAAI;QACF,MAAMd,WAAW,MAAMlB,cACpBmB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAACP,QAAQR,MAAM,EAClBc,UAAU,CAAC,aACXE,KAAK,CAAC,KACNC,GAAG;QACN,KAAK,MAAMC,eAAeP,SAASQ,IAAI,CAAE;YACvC,MAAMC,OAAOF,YAAYE,IAAI;YAC7B,IAAIA,CAAAA,wBAAAA,KAAMrB,IAAI,KAAI,CAAC,AAACqB,KAAaC,SAAS,EAAE;oBAK5BD,kBACDA,iBACCA;gBANd,qDAAqD;gBACrD,2DAA2D;gBAC3D,MAAMM,aAAa;oBACjB3B,MAAMqB,KAAKrB,IAAI;oBACf4B,UAAU,GAAEP,mBAAAA,KAAKO,UAAU,YAAfP,mBAAmB,EAAE;oBACjCV,SAAS,GAAEU,kBAAAA,KAAKV,SAAS,YAAdU,kBAAkB,EAAE;oBAC/BQ,UAAU,GAAER,mBAAAA,KAAKQ,UAAU,YAAfR,mBAAmB,EAAE;mBAC7BA,KAAKS,WAAW,IAAI;oBAAEA,aAAaT,KAAKS,WAAW;gBAAC;gBAE1D,4DAA4D;gBAC5D,gEAAgE;gBAChE,aAAa;gBACbJ,SAAS,CAACP,YAAYI,EAAE,CAAC,GAAGI;gBAC5B,IAAI,CAACD,SAAS,CAACL,KAAKrB,IAAI,CAAC,EAAE0B,SAAS,CAACL,KAAKrB,IAAI,CAAC,GAAG2B;YACpD;QACF;IACF,EAAE,OAAOrB,OAAO;QACdC,QAAQD,KAAK,CAACA;IAChB;IACA,OAAOoB;AACT;AAEA,eAAelB,aAAY"}
@@ -14,7 +14,7 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */
17
- import type { HostActionAlert } from '@aglyn/aglyn/server';
17
+ import type { SiteAlert } from '@aglyn/aglyn/server';
18
18
  /**
19
19
  * WHO HEARS A HOST EVENT.
20
20
  *
@@ -75,15 +75,16 @@ export interface HostEventListener {
75
75
  * Resolves the site alerts a request/response emitter shows its visitor —
76
76
  * a form submission returns them in its response — or nothing.
77
77
  */
78
- onEvent(hostId: string, event: string, payload: HostEventPayload, context?: HostEventContext): Promise<readonly HostActionAlert[] | void>;
78
+ onEvent(hostId: string, event: string, payload: HostEventPayload, context?: HostEventContext): Promise<readonly SiteAlert[] | void>;
79
79
  /**
80
- * Runs ONE automation, by id, that a published page fired for a site event
81
- * it evaluated itself — a scroll depth, a click, exit intent. The page has
82
- * already decided the trigger matched, so the listener runs that one
83
- * automation rather than every automation on the event; a listener that
84
- * does not own the id resolves nothing.
80
+ * Runs the server half of ONE page interaction, by id, that a published
81
+ * page fired for a site event it evaluated itself — a scroll depth, a
82
+ * click, exit intent. The page has already decided the trigger matched, so
83
+ * the listener runs what that one interaction names rather than everything
84
+ * listening for the event; a listener that does not own the id resolves
85
+ * nothing.
85
86
  */
86
- onDispatch?(hostId: string, automationId: string, event: string, payload: HostEventPayload, context?: HostEventContext): Promise<readonly HostActionAlert[] | void>;
87
+ onDispatch?(hostId: string, interactionId: string, event: string, payload: HostEventPayload, context?: HostEventContext): Promise<readonly SiteAlert[] | void>;
87
88
  }
88
89
  /**
89
90
  * Subscribes a plugin to every host event. Registering again under the same
@@ -99,9 +100,9 @@ export declare function resetHostEventListenersForTests(): void;
99
100
  * Hands one event to every listener, concurrently, and gathers the alerts
100
101
  * they produced. Never rejects.
101
102
  */
102
- export declare function runHostEventListeners(hostId: string, event: string, payload?: HostEventPayload, context?: HostEventContext): Promise<HostActionAlert[]>;
103
+ export declare function runHostEventListeners(hostId: string, event: string, payload?: HostEventPayload, context?: HostEventContext): Promise<SiteAlert[]>;
103
104
  /**
104
- * Hands one page-dispatched automation to the listeners that dispatch, and
105
+ * Hands one page-dispatched interaction to the listeners that dispatch, and
105
106
  * gathers the alerts it produced. Never rejects.
106
107
  */
107
- export declare function dispatchHostAutomation(hostId: string, automationId: string, event: string, payload?: HostEventPayload, context?: HostEventContext): Promise<HostActionAlert[]>;
108
+ export declare function dispatchHostAutomation(hostId: string, interactionId: string, event: string, payload?: HostEventPayload, context?: HostEventContext): Promise<SiteAlert[]>;
@@ -73,16 +73,16 @@
73
73
  return settled.flat();
74
74
  }
75
75
  /**
76
- * Hands one page-dispatched automation to the listeners that dispatch, and
76
+ * Hands one page-dispatched interaction to the listeners that dispatch, and
77
77
  * gathers the alerts it produced. Never rejects.
78
- */ export async function dispatchHostAutomation(hostId, automationId, event, payload = {}, context = {}) {
78
+ */ export async function dispatchHostAutomation(hostId, interactionId, event, payload = {}, context = {}) {
79
79
  const alerts = [];
80
80
  for (const { pluginId, listener } of registrations){
81
81
  if (!listener.onDispatch) continue;
82
82
  try {
83
- alerts.push(...alertsOf(await listener.onDispatch(hostId, automationId, event, payload, context)));
83
+ alerts.push(...alertsOf(await listener.onDispatch(hostId, interactionId, event, payload, context)));
84
84
  } catch (error) {
85
- console.error(`[host-events] ${pluginId} failed to dispatch ${automationId}`, hostId, error);
85
+ console.error(`[host-events] ${pluginId} failed to dispatch ${interactionId}`, hostId, error);
86
86
  }
87
87
  }
88
88
  return alerts;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/host-event-listeners.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { HostActionAlert } from '@aglyn/aglyn/server'\n\n/**\n * WHO HEARS A HOST EVENT.\n *\n * A host event — a form submitted, a member signed up, a deal won — is raised\n * by the server door that performed the write, through `emitHostEvent`. What\n * runs because of it is not the runtime's business: an automation engine, an\n * integration, a marketplace plugin. Each registers a listener here and the\n * runtime fans every event out to all of them, knowing none by name.\n *\n * ## Registered by a call, at boot\n *\n * A listener is registered by a function the platform calls BY NAME — a\n * plugin's `serverDeclarations` entry, which both apps run once per process\n * from `instrumentation.ts`, and its API register functions, which the plugin\n * loader runs. Never by a bare `import './x'`: a bundler drops a module its\n * package does not list as a side effect, and jest does not, so a registration\n * that relies on one passes every spec and runs in no build (AGL-3025).\n *\n * Boot registration is what lets a core route that never loads a plugin — the\n * form submission, the page-view collector — still reach every listener.\n *\n * ## Isolated, and never thrown into the emitter\n *\n * The event has already happened: the submission is stored, the member exists.\n * A listener that throws is logged against its plugin and the others still\n * run, and the emitting request never sees the failure.\n */\n\n/** What an event carries into everything that listens for it. */\nexport type HostEventPayload = Record<string, string | number | boolean>\n\n/**\n * Who caused an event (AGL-3376): the console member who moved the deal, the\n * integration's key that added the contact, the visitor who submitted the\n * form, or the platform acting on its own (a Stripe webhook, a schedule).\n *\n * Kept OUT of the payload on purpose. The payload seeds a workflow's\n * expression scope and condition editor, and a site's automations have no\n * business reading a console member's address; this rides beside it, for\n * the record of who set a run off.\n */\nexport interface HostEventActor {\n kind: 'member' | 'visitor' | 'apiKey' | 'platform'\n /** The acting account, for a member. */\n uid?: string | null\n /** The address the actor had when they acted, when the door knew it. */\n email?: string | null\n /** The key's name, for an integration. */\n apiKeyName?: string | null\n}\n\n/** What rides beside an event's payload. */\nexport interface HostEventContext {\n /** Absent when the door that raised the event did not know. */\n actor?: HostEventActor\n}\n\nexport interface HostEventListener {\n /**\n * Runs whatever this plugin does when `event` happens on the site.\n *\n * Resolves the site alerts a request/response emitter shows its visitor —\n * a form submission returns them in its response — or nothing.\n */\n onEvent(\n hostId: string,\n event: string,\n payload: HostEventPayload,\n context?: HostEventContext,\n ): Promise<readonly HostActionAlert[] | void>\n /**\n * Runs ONE automation, by id, that a published page fired for a site event\n * it evaluated itself — a scroll depth, a click, exit intent. The page has\n * already decided the trigger matched, so the listener runs that one\n * automation rather than every automation on the event; a listener that\n * does not own the id resolves nothing.\n */\n onDispatch?(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload,\n context?: HostEventContext,\n ): Promise<readonly HostActionAlert[] | void>\n}\n\ninterface Registration {\n pluginId: string\n listener: HostEventListener\n}\n\nconst registrations: Registration[] = []\n\n/** Whether the no-listener warning has been printed in this process. */\nlet warnedEmpty = false\n\n/** A listener's answer as a list: nothing it said counts as no alerts. */\nfunction alertsOf(answer: readonly HostActionAlert[] | void): HostActionAlert[] {\n return Array.isArray(answer) ? [...answer] : []\n}\n\n/**\n * Subscribes a plugin to every host event. Registering again under the same\n * plugin id replaces the earlier listener in place, so a register function\n * the platform calls twice — boot, then the API surface — leaves one.\n */\nexport function registerHostEventListener(\n pluginId: string,\n listener: HostEventListener,\n): void {\n const id = String(pluginId ?? '').trim()\n if (!id) {\n throw new Error('a host event listener needs the id of the plugin it belongs to')\n }\n const at = registrations.findIndex((entry) => entry.pluginId === id)\n if (at >= 0) registrations[at] = { pluginId: id, listener }\n else registrations.push({ pluginId: id, listener })\n}\n\n/** The plugins listening, in registration order, for diagnostics. */\nexport function listHostEventListeners(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every listener. */\nexport function resetHostEventListenersForTests(): void {\n registrations.length = 0\n warnedEmpty = false\n}\n\n/**\n * Hands one event to every listener, concurrently, and gathers the alerts\n * they produced. Never rejects.\n */\nexport async function runHostEventListeners(\n hostId: string,\n event: string,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<HostActionAlert[]> {\n if (!registrations.length) {\n /*\n * Said once per process rather than per event. No listener means the\n * plugins' server declarations never ran here — every automation on\n * every site served by this process is silent, and a line in the log is\n * the only place that is visible.\n */\n if (!warnedEmpty) {\n warnedEmpty = true\n console.warn(\n `[host-events] nothing is listening; \"${event}\" on ${hostId} ran no ` +\n 'automation. Listeners register from the plugins’ server ' +\n 'declarations at boot.',\n )\n }\n return []\n }\n const settled = await Promise.all(\n registrations.map(async ({ pluginId, listener }) => {\n try {\n return alertsOf(await listener.onEvent(hostId, event, payload, context))\n } catch (error) {\n console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error)\n return []\n }\n }),\n )\n return settled.flat()\n}\n\n/**\n * Hands one page-dispatched automation to the listeners that dispatch, and\n * gathers the alerts it produced. Never rejects.\n */\nexport async function dispatchHostAutomation(\n hostId: string,\n automationId: string,\n event: string,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<HostActionAlert[]> {\n const alerts: HostActionAlert[] = []\n for (const { pluginId, listener } of registrations) {\n if (!listener.onDispatch) continue\n try {\n alerts.push(\n ...alertsOf(\n await listener.onDispatch(hostId, automationId, event, payload, context),\n ),\n )\n } catch (error) {\n console.error(\n `[host-events] ${pluginId} failed to dispatch ${automationId}`,\n hostId,\n error,\n )\n }\n }\n return alerts\n}\n"],"names":["registrations","warnedEmpty","alertsOf","answer","Array","isArray","registerHostEventListener","pluginId","listener","id","String","trim","Error","at","findIndex","entry","push","listHostEventListeners","map","resetHostEventListenersForTests","length","runHostEventListeners","hostId","event","payload","context","console","warn","settled","Promise","all","onEvent","error","flat","dispatchHostAutomation","automationId","alerts","onDispatch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GA+FD,MAAMA,gBAAgC,EAAE;AAExC,sEAAsE,GACtE,IAAIC,cAAc;AAElB,wEAAwE,GACxE,SAASC,SAASC,MAAyC;IACzD,OAAOC,MAAMC,OAAO,CAACF,UAAU;WAAIA;KAAO,GAAG,EAAE;AACjD;AAEA;;;;CAIC,GACD,OAAO,SAASG,0BACdC,QAAgB,EAChBC,QAA2B;IAE3B,MAAMC,KAAKC,OAAOH,mBAAAA,WAAY,IAAII,IAAI;IACtC,IAAI,CAACF,IAAI;QACP,MAAM,IAAIG,MAAM;IAClB;IACA,MAAMC,KAAKb,cAAcc,SAAS,CAAC,CAACC,QAAUA,MAAMR,QAAQ,KAAKE;IACjE,IAAII,MAAM,GAAGb,aAAa,CAACa,GAAG,GAAG;QAAEN,UAAUE;QAAID;IAAS;SACrDR,cAAcgB,IAAI,CAAC;QAAET,UAAUE;QAAID;IAAS;AACnD;AAEA,mEAAmE,GACnE,OAAO,SAASS;IACd,OAAOjB,cAAckB,GAAG,CAAC,CAACH,QAAUA,MAAMR,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASY;IACdnB,cAAcoB,MAAM,GAAG;IACvBnB,cAAc;AAChB;AAEA;;;CAGC,GACD,OAAO,eAAeoB,sBACpBC,MAAc,EACdC,KAAa,EACbC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,IAAI,CAACzB,cAAcoB,MAAM,EAAE;QACzB;;;;;KAKC,GACD,IAAI,CAACnB,aAAa;YAChBA,cAAc;YACdyB,QAAQC,IAAI,CACV,CAAC,qCAAqC,EAAEJ,MAAM,KAAK,EAAED,OAAO,QAAQ,CAAC,GACnE,6DACA;QAEN;QACA,OAAO,EAAE;IACX;IACA,MAAMM,UAAU,MAAMC,QAAQC,GAAG,CAC/B9B,cAAckB,GAAG,CAAC,OAAO,EAAEX,QAAQ,EAAEC,QAAQ,EAAE;QAC7C,IAAI;YACF,OAAON,SAAS,MAAMM,SAASuB,OAAO,CAACT,QAAQC,OAAOC,SAASC;QACjE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CAAC,CAAC,cAAc,EAAEzB,SAAS,WAAW,EAAEgB,OAAO,EAAED,QAAQU;YACtE,OAAO,EAAE;QACX;IACF;IAEF,OAAOJ,QAAQK,IAAI;AACrB;AAEA;;;CAGC,GACD,OAAO,eAAeC,uBACpBZ,MAAc,EACda,YAAoB,EACpBZ,KAAa,EACbC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,MAAMW,SAA4B,EAAE;IACpC,KAAK,MAAM,EAAE7B,QAAQ,EAAEC,QAAQ,EAAE,IAAIR,cAAe;QAClD,IAAI,CAACQ,SAAS6B,UAAU,EAAE;QAC1B,IAAI;YACFD,OAAOpB,IAAI,IACNd,SACD,MAAMM,SAAS6B,UAAU,CAACf,QAAQa,cAAcZ,OAAOC,SAASC;QAGtE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CACX,CAAC,cAAc,EAAEzB,SAAS,oBAAoB,EAAE4B,cAAc,EAC9Db,QACAU;QAEJ;IACF;IACA,OAAOI;AACT"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/host-event-listeners.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { SiteAlert } from '@aglyn/aglyn/server'\n\n/**\n * WHO HEARS A HOST EVENT.\n *\n * A host event — a form submitted, a member signed up, a deal won — is raised\n * by the server door that performed the write, through `emitHostEvent`. What\n * runs because of it is not the runtime's business: an automation engine, an\n * integration, a marketplace plugin. Each registers a listener here and the\n * runtime fans every event out to all of them, knowing none by name.\n *\n * ## Registered by a call, at boot\n *\n * A listener is registered by a function the platform calls BY NAME — a\n * plugin's `serverDeclarations` entry, which both apps run once per process\n * from `instrumentation.ts`, and its API register functions, which the plugin\n * loader runs. Never by a bare `import './x'`: a bundler drops a module its\n * package does not list as a side effect, and jest does not, so a registration\n * that relies on one passes every spec and runs in no build (AGL-3025).\n *\n * Boot registration is what lets a core route that never loads a plugin — the\n * form submission, the page-view collector — still reach every listener.\n *\n * ## Isolated, and never thrown into the emitter\n *\n * The event has already happened: the submission is stored, the member exists.\n * A listener that throws is logged against its plugin and the others still\n * run, and the emitting request never sees the failure.\n */\n\n/** What an event carries into everything that listens for it. */\nexport type HostEventPayload = Record<string, string | number | boolean>\n\n/**\n * Who caused an event (AGL-3376): the console member who moved the deal, the\n * integration's key that added the contact, the visitor who submitted the\n * form, or the platform acting on its own (a Stripe webhook, a schedule).\n *\n * Kept OUT of the payload on purpose. The payload seeds a workflow's\n * expression scope and condition editor, and a site's automations have no\n * business reading a console member's address; this rides beside it, for\n * the record of who set a run off.\n */\nexport interface HostEventActor {\n kind: 'member' | 'visitor' | 'apiKey' | 'platform'\n /** The acting account, for a member. */\n uid?: string | null\n /** The address the actor had when they acted, when the door knew it. */\n email?: string | null\n /** The key's name, for an integration. */\n apiKeyName?: string | null\n}\n\n/** What rides beside an event's payload. */\nexport interface HostEventContext {\n /** Absent when the door that raised the event did not know. */\n actor?: HostEventActor\n}\n\nexport interface HostEventListener {\n /**\n * Runs whatever this plugin does when `event` happens on the site.\n *\n * Resolves the site alerts a request/response emitter shows its visitor —\n * a form submission returns them in its response — or nothing.\n */\n onEvent(\n hostId: string,\n event: string,\n payload: HostEventPayload,\n context?: HostEventContext,\n ): Promise<readonly SiteAlert[] | void>\n /**\n * Runs the server half of ONE page interaction, by id, that a published\n * page fired for a site event it evaluated itself — a scroll depth, a\n * click, exit intent. The page has already decided the trigger matched, so\n * the listener runs what that one interaction names rather than everything\n * listening for the event; a listener that does not own the id resolves\n * nothing.\n */\n onDispatch?(\n hostId: string,\n interactionId: string,\n event: string,\n payload: HostEventPayload,\n context?: HostEventContext,\n ): Promise<readonly SiteAlert[] | void>\n}\n\ninterface Registration {\n pluginId: string\n listener: HostEventListener\n}\n\nconst registrations: Registration[] = []\n\n/** Whether the no-listener warning has been printed in this process. */\nlet warnedEmpty = false\n\n/** A listener's answer as a list: nothing it said counts as no alerts. */\nfunction alertsOf(answer: readonly SiteAlert[] | void): SiteAlert[] {\n return Array.isArray(answer) ? [...answer] : []\n}\n\n/**\n * Subscribes a plugin to every host event. Registering again under the same\n * plugin id replaces the earlier listener in place, so a register function\n * the platform calls twice — boot, then the API surface — leaves one.\n */\nexport function registerHostEventListener(\n pluginId: string,\n listener: HostEventListener,\n): void {\n const id = String(pluginId ?? '').trim()\n if (!id) {\n throw new Error('a host event listener needs the id of the plugin it belongs to')\n }\n const at = registrations.findIndex((entry) => entry.pluginId === id)\n if (at >= 0) registrations[at] = { pluginId: id, listener }\n else registrations.push({ pluginId: id, listener })\n}\n\n/** The plugins listening, in registration order, for diagnostics. */\nexport function listHostEventListeners(): string[] {\n return registrations.map((entry) => entry.pluginId)\n}\n\n/** Test seam: forget every listener. */\nexport function resetHostEventListenersForTests(): void {\n registrations.length = 0\n warnedEmpty = false\n}\n\n/**\n * Hands one event to every listener, concurrently, and gathers the alerts\n * they produced. Never rejects.\n */\nexport async function runHostEventListeners(\n hostId: string,\n event: string,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<SiteAlert[]> {\n if (!registrations.length) {\n /*\n * Said once per process rather than per event. No listener means the\n * plugins' server declarations never ran here — every automation on\n * every site served by this process is silent, and a line in the log is\n * the only place that is visible.\n */\n if (!warnedEmpty) {\n warnedEmpty = true\n console.warn(\n `[host-events] nothing is listening; \"${event}\" on ${hostId} ran no ` +\n 'automation. Listeners register from the plugins’ server ' +\n 'declarations at boot.',\n )\n }\n return []\n }\n const settled = await Promise.all(\n registrations.map(async ({ pluginId, listener }) => {\n try {\n return alertsOf(await listener.onEvent(hostId, event, payload, context))\n } catch (error) {\n console.error(`[host-events] ${pluginId} failed on ${event}`, hostId, error)\n return []\n }\n }),\n )\n return settled.flat()\n}\n\n/**\n * Hands one page-dispatched interaction to the listeners that dispatch, and\n * gathers the alerts it produced. Never rejects.\n */\nexport async function dispatchHostAutomation(\n hostId: string,\n interactionId: string,\n event: string,\n payload: HostEventPayload = {},\n context: HostEventContext = {},\n): Promise<SiteAlert[]> {\n const alerts: SiteAlert[] = []\n for (const { pluginId, listener } of registrations) {\n if (!listener.onDispatch) continue\n try {\n alerts.push(\n ...alertsOf(\n await listener.onDispatch(hostId, interactionId, event, payload, context),\n ),\n )\n } catch (error) {\n console.error(\n `[host-events] ${pluginId} failed to dispatch ${interactionId}`,\n hostId,\n error,\n )\n }\n }\n return alerts\n}\n"],"names":["registrations","warnedEmpty","alertsOf","answer","Array","isArray","registerHostEventListener","pluginId","listener","id","String","trim","Error","at","findIndex","entry","push","listHostEventListeners","map","resetHostEventListenersForTests","length","runHostEventListeners","hostId","event","payload","context","console","warn","settled","Promise","all","onEvent","error","flat","dispatchHostAutomation","interactionId","alerts","onDispatch"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAgGD,MAAMA,gBAAgC,EAAE;AAExC,sEAAsE,GACtE,IAAIC,cAAc;AAElB,wEAAwE,GACxE,SAASC,SAASC,MAAmC;IACnD,OAAOC,MAAMC,OAAO,CAACF,UAAU;WAAIA;KAAO,GAAG,EAAE;AACjD;AAEA;;;;CAIC,GACD,OAAO,SAASG,0BACdC,QAAgB,EAChBC,QAA2B;IAE3B,MAAMC,KAAKC,OAAOH,mBAAAA,WAAY,IAAII,IAAI;IACtC,IAAI,CAACF,IAAI;QACP,MAAM,IAAIG,MAAM;IAClB;IACA,MAAMC,KAAKb,cAAcc,SAAS,CAAC,CAACC,QAAUA,MAAMR,QAAQ,KAAKE;IACjE,IAAII,MAAM,GAAGb,aAAa,CAACa,GAAG,GAAG;QAAEN,UAAUE;QAAID;IAAS;SACrDR,cAAcgB,IAAI,CAAC;QAAET,UAAUE;QAAID;IAAS;AACnD;AAEA,mEAAmE,GACnE,OAAO,SAASS;IACd,OAAOjB,cAAckB,GAAG,CAAC,CAACH,QAAUA,MAAMR,QAAQ;AACpD;AAEA,sCAAsC,GACtC,OAAO,SAASY;IACdnB,cAAcoB,MAAM,GAAG;IACvBnB,cAAc;AAChB;AAEA;;;CAGC,GACD,OAAO,eAAeoB,sBACpBC,MAAc,EACdC,KAAa,EACbC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,IAAI,CAACzB,cAAcoB,MAAM,EAAE;QACzB;;;;;KAKC,GACD,IAAI,CAACnB,aAAa;YAChBA,cAAc;YACdyB,QAAQC,IAAI,CACV,CAAC,qCAAqC,EAAEJ,MAAM,KAAK,EAAED,OAAO,QAAQ,CAAC,GACnE,6DACA;QAEN;QACA,OAAO,EAAE;IACX;IACA,MAAMM,UAAU,MAAMC,QAAQC,GAAG,CAC/B9B,cAAckB,GAAG,CAAC,OAAO,EAAEX,QAAQ,EAAEC,QAAQ,EAAE;QAC7C,IAAI;YACF,OAAON,SAAS,MAAMM,SAASuB,OAAO,CAACT,QAAQC,OAAOC,SAASC;QACjE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CAAC,CAAC,cAAc,EAAEzB,SAAS,WAAW,EAAEgB,OAAO,EAAED,QAAQU;YACtE,OAAO,EAAE;QACX;IACF;IAEF,OAAOJ,QAAQK,IAAI;AACrB;AAEA;;;CAGC,GACD,OAAO,eAAeC,uBACpBZ,MAAc,EACda,aAAqB,EACrBZ,KAAa,EACbC,UAA4B,CAAC,CAAC,EAC9BC,UAA4B,CAAC,CAAC;IAE9B,MAAMW,SAAsB,EAAE;IAC9B,KAAK,MAAM,EAAE7B,QAAQ,EAAEC,QAAQ,EAAE,IAAIR,cAAe;QAClD,IAAI,CAACQ,SAAS6B,UAAU,EAAE;QAC1B,IAAI;YACFD,OAAOpB,IAAI,IACNd,SACD,MAAMM,SAAS6B,UAAU,CAACf,QAAQa,eAAeZ,OAAOC,SAASC;QAGvE,EAAE,OAAOO,OAAO;YACdN,QAAQM,KAAK,CACX,CAAC,cAAc,EAAEzB,SAAS,oBAAoB,EAAE4B,eAAe,EAC/Db,QACAU;QAEJ;IACF;IACA,OAAOI;AACT"}
@@ -1,189 +0,0 @@
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
- * A lead becomes a contact, and optionally a company and a deal (AGL-2608,
19
- * AGL-2627).
20
- *
21
- * ## Why one function under two doors
22
- *
23
- * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`
24
- * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two
25
- * doors authenticate differently — an ID token against the site's role, an
26
- * API key against its scopes — and answer in different envelopes, but what
27
- * they DO has to be one thing: a conversion that opened a second deal for
28
- * one person because two callers reached the same lead through two
29
- * implementations is the bug this file exists to make impossible. The doors
30
- * own their transport; this owns the writes.
31
- *
32
- * It lives here, in the tenancy runtime, rather than beside the plugin
33
- * route it came from, because the console's REST layer may not import a
34
- * feature plugin (the app→addons boundary), while both the plugin and the
35
- * console may import the runtime — which already holds the capture door
36
- * and the owner assignment this conversion goes through.
37
- *
38
- * ## Why a server function and not four client writes
39
- *
40
- * The lead is a host document the browser may edit, and a company or a deal
41
- * is a client-creatable org document. What the browser cannot do is CREATE A
42
- * CONTACT: the contacts collection is written only by `captureHostContact`
43
- * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`
44
- * fires), because that function is the dedupe — one human, one row, found by
45
- * normalized address across every site in the org — and the audience-band
46
- * gate. A client that wrote a contact document of its own would mint a
47
- * second row for a person the org already holds, and would do it past the
48
- * band.
49
- *
50
- * Once the contact has to come from the server, the rest follows it: the
51
- * conversion is one act with four writes, and a caller that made three of
52
- * them and lost the connection before the fourth leaves a lead marked
53
- * qualified with no contact, or a deal pointing at a contact that was never
54
- * stamped on the lead. Here the order is fixed — contact, then company, then
55
- * deal, then the lead — and the lead is stamped LAST, so a lead that carries
56
- * `convertedContactId` names a contact that exists.
57
- *
58
- * ## Idempotent on the lead
59
- *
60
- * A lead already carrying `convertedContactId` answers with the ids it has
61
- * and creates nothing more. A double-click on the dialog's button, a retry
62
- * after a slow response, or a Zapier zap replaying a step must not open a
63
- * second deal for one person.
64
- *
65
- * ## The actor
66
- *
67
- * A signed-in member converting from the console owns what nobody else
68
- * claimed: their uid is the owner of last resort, written directly rather
69
- * than through the deliberate reassignment, because they may be staff
70
- * converting on a workspace's behalf and a roster check would refuse the one
71
- * person who is actually here. An API key is not a person and is on no
72
- * roster, so for an `api` actor that last step is skipped and a contact
73
- * nobody chose an owner for — and no rule assigned — stays unowned, which
74
- * the console renders honestly as unassigned. Either way the caller has
75
- * already been authorized by its door; nothing here asks again.
76
- */
77
- import { type CrmDealStage, type CrmPipeline } from '@aglyn/aglyn/server';
78
- /** Who is converting, as far as the writes need to know. */
79
- export interface LeadConvertActor {
80
- /**
81
- * What `createdByUid` records on every record the conversion opens, and
82
- * who the audit line names. A member's uid, or the literal `'api'` for a
83
- * key — the same attribution the REST creates stamp.
84
- */
85
- uid: string;
86
- email?: string | null;
87
- /**
88
- * `member`: a signed-in person, who becomes the owner when nobody else
89
- * does. `api`: a key, which cannot own a record — see the header.
90
- */
91
- kind: 'member' | 'api';
92
- /**
93
- * For an `api` actor, the key's name — what the audit line attributes the
94
- * conversion to, since a key has no address to be named by.
95
- */
96
- apiKeyName?: string | null;
97
- }
98
- /** What a conversion is asked to do. Validated by the door before it gets here. */
99
- export interface ConvertHostLeadInput {
100
- firestore: FirebaseFirestore.Firestore;
101
- hostId: string;
102
- orgId: string;
103
- /** The org document, as the door resolved it. */
104
- org: Record<string, unknown>;
105
- /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */
106
- leadId: string;
107
- actor: LeadConvertActor;
108
- /**
109
- * Who owns the resulting contact (and deal). Defaults to the lead's own
110
- * owner; failing that the org's assignment rules and the site's default
111
- * owner decide (AGL-2618), and failing those a member actor — somebody
112
- * converted this person, and a record with no owner is one nobody follows
113
- * up.
114
- */
115
- ownerUid?: string;
116
- /** Link an existing `orgs/{orgId}/companies/{companyId}`. */
117
- companyId?: string;
118
- /** Or create one. Ignored when `companyId` is given. Already normalized. */
119
- createCompany?: {
120
- name: string;
121
- domain: string | null;
122
- } | null;
123
- /** Open a deal in the org's default pipeline. Already normalized. */
124
- deal?: {
125
- title: string;
126
- amountCents: number | null;
127
- /** Lowercase ISO 4217. */
128
- currency: string;
129
- /** A stage of the default pipeline; its first open stage when absent. */
130
- stageId?: string;
131
- } | null;
132
- }
133
- /**
134
- * Why a conversion did not happen. Each is a state of the data, not a fault
135
- * of the request — the door has already refused a malformed body — and each
136
- * leaves the lead unconverted so a retry after the remedy finds it where it
137
- * was.
138
- */
139
- export type ConvertHostLeadRefusal =
140
- /** No document at `hosts/{hostId}/leads/{leadId}`. */
141
- 'unknown-lead'
142
- /** The lead's address cannot become a contact. */
143
- | 'no-email'
144
- /**
145
- * The capture door produced no contact — it swallows its own failures and
146
- * drops a creation past a Free org's audience band. Nothing was written.
147
- */
148
- | 'contact-not-created'
149
- /**
150
- * The site holds an erasure for the lead's address (AGL-2623): the person
151
- * asked to be forgotten, and the capture door refuses to rebuild a record
152
- * for them — by a conversion as by a form. Nothing was written, and the
153
- * lead stays as it was; the erasure removes it when it runs.
154
- */
155
- | 'erased'
156
- /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */
157
- | 'band-full'
158
- /** `companyId` names no company in this organization. The contact stands. */
159
- | 'unknown-company'
160
- /** The default pipeline has no stages to open a deal in. The contact stands. */
161
- | 'no-stages';
162
- export type ConvertHostLeadResult = {
163
- ok: true;
164
- contactId: string;
165
- companyId?: string;
166
- dealId?: string;
167
- /** The lead was converted before this call; nothing was created now. */
168
- alreadyConverted: boolean;
169
- } | {
170
- ok: false;
171
- reason: ConvertHostLeadRefusal;
172
- };
173
- /**
174
- * The stage a new deal opens in.
175
- *
176
- * The caller's choice when the pipeline has it, otherwise the first OPEN
177
- * stage by order — a deal created by converting a lead is by definition not
178
- * yet won or lost, so defaulting to a closed stage would record an outcome
179
- * nobody reached. A pipeline with no open stage at all falls back to its
180
- * first stage rather than refusing: the merchant edited their pipeline into
181
- * that shape, and a conversion that failed because of it would read as a bug
182
- * in the lead.
183
- */
184
- export declare function stageForNewDeal(pipeline: Pick<CrmPipeline, 'stages'>, requestedStageId: string | undefined): CrmDealStage | null;
185
- /**
186
- * Convert one lead. Throws only on infrastructure failure; every refusal
187
- * the data can produce is a `ConvertHostLeadResult`.
188
- */
189
- export declare function convertHostLead(input: ConvertHostLeadInput): Promise<ConvertHostLeadResult>;