@aglyn/tenant-runtime 1.0.0-beta.176 → 1.0.0-beta.184

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aglyn/tenant-runtime",
3
- "version": "1.0.0-beta.176",
3
+ "version": "1.0.0-beta.184",
4
4
  "license": "Apache-2.0",
5
5
  "homepage": "https://aglyn.com",
6
6
  "repository": {
@@ -25,8 +25,8 @@
25
25
  "./package.json": "./package.json"
26
26
  },
27
27
  "dependencies": {
28
- "@aglyn/aglyn": "1.0.0-beta.176",
29
- "@aglyn/tenant-data-admin": "1.0.0-beta.176",
28
+ "@aglyn/aglyn": "1.0.0-beta.184",
29
+ "@aglyn/tenant-data-admin": "1.0.0-beta.184",
30
30
  "@swc/helpers": "0.5.23"
31
31
  },
32
32
  "peerDependencies": {
@@ -166,7 +166,7 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
166
166
  * record rather than another form capture.
167
167
  *
168
168
  * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce
169
- * hands a lead's fields to the contact: phone, title, address, the
169
+ * hands a lead's fields to the contact: phone, title, lead source, address, the
170
170
  * company as text (until step 2 links a record, whose own name then
171
171
  * replaces it), tags, and the marketing basis the lead recorded — the
172
172
  * person ticked the box on the lead, and a conversion must not be the
@@ -199,6 +199,8 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
199
199
  phone: lead.phone
200
200
  } : {}, typeof lead.jobTitle === 'string' && lead.jobTitle ? {
201
201
  jobTitle: lead.jobTitle
202
+ } : {}, typeof lead.leadSource === 'string' && lead.leadSource ? {
203
+ leadSource: lead.leadSource
202
204
  } : {}, lead.address && typeof lead.address === 'object' ? {
203
205
  address: lead.address
204
206
  } : {}, typeof lead.company === 'string' && lead.company ? {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/convert-host-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A lead becomes a contact, and optionally a company and a deal (AGL-2608,\n * AGL-2627).\n *\n * ## Why one function under two doors\n *\n * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`\n * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two\n * doors authenticate differently — an ID token against the site's role, an\n * API key against its scopes — and answer in different envelopes, but what\n * they DO has to be one thing: a conversion that opened a second deal for\n * one person because two callers reached the same lead through two\n * implementations is the bug this file exists to make impossible. The doors\n * own their transport; this owns the writes.\n *\n * It lives here, in the tenancy runtime, rather than beside the plugin\n * route it came from, because the console's REST layer may not import a\n * feature plugin (the app→addons boundary), while both the plugin and the\n * console may import the runtime — which already holds the capture door\n * and the owner assignment this conversion goes through.\n *\n * ## Why a server function and not four client writes\n *\n * The lead is a host document the browser may edit, and a company or a deal\n * is a client-creatable org document. What the browser cannot do is CREATE A\n * CONTACT: the contacts collection is written only by `captureHostContact`\n * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`\n * fires), because that function is the dedupe — one human, one row, found by\n * normalized address across every site in the org — and the audience-band\n * gate. A client that wrote a contact document of its own would mint a\n * second row for a person the org already holds, and would do it past the\n * band.\n *\n * Once the contact has to come from the server, the rest follows it: the\n * conversion is one act with four writes, and a caller that made three of\n * them and lost the connection before the fourth leaves a lead marked\n * qualified with no contact, or a deal pointing at a contact that was never\n * stamped on the lead. Here the order is fixed — contact, then company, then\n * deal, then the lead — and the lead is stamped LAST, so a lead that carries\n * `convertedContactId` names a contact that exists.\n *\n * ## Idempotent on the lead\n *\n * A lead already carrying `convertedContactId` answers with the ids it has\n * and creates nothing more. A double-click on the dialog's button, a retry\n * after a slow response, or a Zapier zap replaying a step must not open a\n * second deal for one person.\n *\n * ## The actor\n *\n * A signed-in member converting from the console owns what nobody else\n * claimed: their uid is the owner of last resort, written directly rather\n * than through the deliberate reassignment, because they may be staff\n * converting on a workspace's behalf and a roster check would refuse the one\n * person who is actually here. An API key is not a person and is on no\n * roster, so for an `api` actor that last step is skipped and a contact\n * nobody chose an owner for — and no rule assigned — stays unowned, which\n * the console renders honestly as unassigned. Either way the caller has\n * already been authorized by its door; nothing here asks again.\n */\n\nimport {\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { assignOwnerForCapture, notifyRecordAssigned } from './assign-contact-owner'\nimport { captureHostContact } from './capture-host-contact'\nimport { handOffLeadRecords } from './hand-off-lead'\n\n/** Who is converting, as far as the writes need to know. */\nexport interface LeadConvertActor {\n /**\n * What `createdByUid` records on every record the conversion opens, and\n * who the audit line names. A member's uid, or the literal `'api'` for a\n * key — the same attribution the REST creates stamp.\n */\n uid: string\n email?: string | null\n /**\n * `member`: a signed-in person, who becomes the owner when nobody else\n * does. `api`: a key, which cannot own a record — see the header.\n */\n kind: 'member' | 'api'\n /**\n * For an `api` actor, the key's name — what the audit line attributes the\n * conversion to, since a key has no address to be named by.\n */\n apiKeyName?: string | null\n}\n\n/** What a conversion is asked to do. Validated by the door before it gets here. */\nexport interface ConvertHostLeadInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orgId: string\n /** The org document, as the door resolved it. */\n org: Record<string, unknown>\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n actor: LeadConvertActor\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those a member actor — somebody\n * converted this person, and a record with no owner is one nobody follows\n * up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. Already normalized. */\n createCompany?: { name: string; domain: string | null } | null\n /** Open a deal in the org's default pipeline. Already normalized. */\n deal?: {\n title: string\n amountCents: number | null\n /** Lowercase ISO 4217. */\n currency: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n } | null\n}\n\n/**\n * Why a conversion did not happen. Each is a state of the data, not a fault\n * of the request — the door has already refused a malformed body — and each\n * leaves the lead unconverted so a retry after the remedy finds it where it\n * was.\n */\nexport type ConvertHostLeadRefusal =\n /** No document at `hosts/{hostId}/leads/{leadId}`. */\n | 'unknown-lead'\n /** The lead's address cannot become a contact. */\n | 'no-email'\n /**\n * The capture door produced no contact — it swallows its own failures and\n * drops a creation past a Free org's audience band. Nothing was written.\n */\n | 'contact-not-created'\n /**\n * The site holds an erasure for the lead's address (AGL-2623): the person\n * asked to be forgotten, and the capture door refuses to rebuild a record\n * for them — by a conversion as by a form. Nothing was written, and the\n * lead stays as it was; the erasure removes it when it runs.\n */\n | 'erased'\n /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */\n | 'band-full'\n /** `companyId` names no company in this organization. The contact stands. */\n | 'unknown-company'\n /** The default pipeline has no stages to open a deal in. The contact stands. */\n | 'no-stages'\n\nexport type ConvertHostLeadResult =\n | {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n }\n | { ok: false; reason: ConvertHostLeadRefusal }\n\n/** The stamp the conversion leaves on the lead — see `CrmLeadFields`. */\ntype LeadConversionStamp = Required<\n Pick<CrmLeadFields, 'status' | 'convertedContactId' | 'convertedAtMs'>\n> &\n Pick<CrmLeadFields, 'dealId' | 'companyId' | 'ownerUid'>\n\n/**\n * The stage a new deal opens in.\n *\n * The caller's choice when the pipeline has it, otherwise the first OPEN\n * stage by order — a deal created by converting a lead is by definition not\n * yet won or lost, so defaulting to a closed stage would record an outcome\n * nobody reached. A pipeline with no open stage at all falls back to its\n * first stage rather than refusing: the merchant edited their pipeline into\n * that shape, and a conversion that failed because of it would read as a bug\n * in the lead.\n */\nexport function stageForNewDeal(\n pipeline: Pick<CrmPipeline, 'stages'>,\n requestedStageId: string | undefined,\n): CrmDealStage | null {\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n if (!stages.length) return null\n const requested = requestedStageId\n ? stages.find((stage) => stage.id === requestedStageId)\n : undefined\n return requested ?? stages.find((stage) => stage.kind === 'open') ?? stages[0]\n}\n\n/** A stage's kind as the deal's denormalized status. */\nfunction dealStatusForStage(stage: CrmDealStage): CrmDealStatus {\n return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n}\n\n/**\n * Convert one lead. Throws only on infrastructure failure; every refusal\n * the data can produce is a `ConvertHostLeadResult`.\n */\nexport async function convertHostLead(\n input: ConvertHostLeadInput,\n): Promise<ConvertHostLeadResult> {\n const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input\n const requestedCompanyId = String(input.companyId ?? '').trim()\n // The lead's one home, on the org (AGL-3275). `orgId` is already resolved\n // by the caller, so this needs no second lookup to find it.\n const leadRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .doc(leadId)\n const leadSnapshot = await leadRef.get()\n if (!leadSnapshot.exists) return { ok: false, reason: 'unknown-lead' }\n const lead = (leadSnapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId) {\n return {\n ok: true,\n contactId: lead.convertedContactId,\n ...(lead.companyId ? { companyId: lead.companyId } : {}),\n ...(lead.dealId ? { dealId: lead.dealId } : {}),\n alreadyConverted: true,\n }\n }\n const email = normalizeContactEmail(lead['email'])\n if (!email) return { ok: false, reason: 'no-email' }\n\n const group = await consentGroupForSite(hostId, org)\n const visibleTo = crmScopeTokens(org, group)\n /*\n * What the CALLER may read, for the company-by-domain reuse below: their\n * group's own tokens plus `org`, exactly the set the console's listeners\n * filter on. A company outside it is one they could not open, so linking\n * a contact to it would point at a record the page then 404s on.\n */\n const readableTokens = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...consentGroupScope(group),\n ])\n /*\n * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else\n * whoever was already working the lead. Handed to the capture as the\n * facet's owner, which is what tells the capture's own assignment pass\n * to stand down — a person's choice outranks a rule. When nobody chose,\n * the facet names no owner and the pass runs the org's rules and the\n * site's default for a contact it creates (AGL-2618).\n */\n const pickedOwner = String(input.ownerUid ?? '').trim()\n const chosenOwner =\n pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '')\n const now = Date.now()\n const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined\n\n /*==========================================\n * 1. THE CONTACT — through the one door that dedupes and meters.\n *\n * `source: 'manual'` because a person converted this lead by hand, and\n * the source filter should say so; the interaction names the lead so the\n * contact's timeline can be walked back to what was captured. The facet\n * carries the stage and the owner, which is what makes this a sales\n * record rather than another form capture.\n *\n * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce\n * hands a lead's fields to the contact: phone, title, address, the\n * company as text (until step 2 links a record, whose own name then\n * replaces it), tags, and the marketing basis the lead recorded — the\n * person ticked the box on the lead, and a conversion must not be the\n * moment they lose it. Only what the lead holds is handed over; a value\n * the contact already carries is the contact's, because the capture door\n * writes a profile field only where the facet has none.\n *=========================================*/\n const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : []\n const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId))\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {\n await notifyRecordAssigned({\n hostId,\n orgId,\n ownerUid,\n actorUid: actor.kind === 'member' ? actor.uid : null,\n record: { kind: 'contact', id: contactId },\n who: leadName || email,\n })\n }\n\n /*==========================================\n * 2. THE COMPANY — linked, found by domain, or created.\n *=========================================*/\n let companyId: string | undefined\n if (requestedCompanyId) {\n const companySnapshot = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(requestedCompanyId)\n .get()\n if (!companySnapshot.exists) return { ok: false, reason: 'unknown-company' }\n companyId = requestedCompanyId\n } else if (createCompany) {\n /*\n * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its\n * company as text, and a company the org already files under that\n * name is the account, whatever domain it carries. Then the domain,\n * the key two contacts at one business share — a second company\n * document for `acme.com` would split the account the key exists to\n * join. Only a company the caller can see counts as found — see\n * `readableTokens`.\n */\n const visibleAmong = (\n snapshots: readonly FirebaseFirestore.QueryDocumentSnapshot[],\n ): string | undefined =>\n snapshots.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })?.id\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n companyId = visibleAmong(byName.docs)\n if (!companyId && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n companyId = visibleAmong(byDomain.docs)\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...nameSearchFields(createCompany.name),\n ...(createCompany.domain ? { domain: createCompany.domain } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n companyId = created.id\n }\n }\n if (companyId) {\n /*\n * The one link writer (AGL-2613): the holder's facet field the contact\n * record reads, the queryable top-level mirror the company's contacts\n * card filters on, and the company's contacts count — planned from\n * the row as the capture left it, so a person the org already held\n * under another company is MOVED rather than counted twice.\n */\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: contactData,\n companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),\n groupId: group.groupId,\n companyId,\n })\n }\n\n /*==========================================\n * 3. THE DEAL — in the default pipeline, seeded if the org has none.\n *=========================================*/\n let dealId: string | undefined\n if (deal) {\n const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines)\n let pipelineId: string\n let pipeline: Pick<CrmPipeline, 'stages'>\n const defaults = await pipelinesRef\n .where('isDefault', '==', true)\n .limit(1)\n .get()\n if (!defaults.empty) {\n pipelineId = defaults.docs[0].id\n pipeline = defaults.docs[0].data() as CrmPipeline\n } else {\n /*\n * The deals section's own read shape, so the two agree on which\n * pipeline \"the org's pipeline\" is when none is flagged default: the\n * first by document id of a bounded window. Seeding a SECOND pipeline\n * here while one exists unflagged would leave the merchant with two\n * boards and their deals split between them.\n */\n const any = await pipelinesRef\n .orderBy(FieldPath.documentId())\n .limit(20)\n .get()\n if (!any.empty) {\n pipelineId = any.docs[0].id\n pipeline = any.docs[0].data() as CrmPipeline\n } else {\n // Exactly what the Deals section seeds: one `Sales` pipeline\n // carrying a COPY of the default stages, flagged default.\n const seeded: Omit<CrmPipeline, 'createdAt' | 'updatedAt'> = {\n name: 'Sales',\n stages: [...DEFAULT_DEAL_STAGES],\n isDefault: true,\n visibleTo,\n hostId,\n }\n const created = await pipelinesRef.add({\n ...seeded,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n pipelineId = created.id\n pipeline = seeded\n }\n }\n const stage = stageForNewDeal(pipeline, deal.stageId)\n if (!stage) return { ok: false, reason: 'no-stages' }\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n title: deal.title,\n titleLower: deal.title.toLowerCase(),\n pipelineId,\n stageId: stage.id,\n status: dealStatusForStage(stage),\n ...(deal.amountCents !== null ? { amountCents: deal.amountCents } : {}),\n currency: deal.currency,\n stageChangedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n contactId,\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n dealId = created.id\n }\n\n /*==========================================\n * 4. THE LEAD — stamped last, once everything it names exists.\n *=========================================*/\n const stamp: LeadConversionStamp = {\n status: 'qualified',\n convertedContactId: contactId,\n convertedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n }\n await leadRef.update({ ...stamp, updatedAt: FieldValue.serverTimestamp() })\n\n /*\n * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the\n * contact, and every plugin with a listener — Sequences, for an\n * enrollment naming the lead — re-points its own records. After the\n * stamp, so a listener reading the lead finds the conversion on it; it\n * never throws.\n */\n await handOffLeadRecords({\n firestore,\n orgId,\n hostId,\n leadId,\n contactId,\n email,\n by: actor.kind === 'member' ? 'member' : 'api',\n })\n\n /*\n * The audit line, from the function that did the work (AGL-2622): the\n * conversion is one act however many records it opened, so it is one\n * entry, on the lead, in the feed of the site that holds the lead. A\n * repeat call answered above with the ids it already had wrote nothing\n * and logs nothing.\n */\n await logHostActivity(\n hostId,\n {\n uid: actor.uid,\n email: actor.email ?? null,\n ...(actor.apiKeyName ? { apiKeyName: actor.apiKeyName } : {}),\n },\n 'Converted lead',\n { type: 'lead', id: leadId, name: String(lead['name'] ?? '') || email },\n )\n\n return {\n ok: true,\n contactId,\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n alreadyConverted: false,\n }\n}\n"],"names":["consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","handOffLeadRecords","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","contactSnapshot","actor","lead","firestore","hostId","orgId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","trim","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","Set","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","leadTags","Array","isArray","tags","map","leadConsent","captured","name","source","interaction","summary","refId","basis","marketingConsent","facet","lifecycleStage","phone","jobTitle","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","created","add","createdByUid","createdAt","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","deals","title","titleLower","status","amountCents","currency","stageChangedAtMs","stamp","convertedAtMs","by","apiKeyName","type"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/convert-host-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A lead becomes a contact, and optionally a company and a deal (AGL-2608,\n * AGL-2627).\n *\n * ## Why one function under two doors\n *\n * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`\n * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two\n * doors authenticate differently — an ID token against the site's role, an\n * API key against its scopes — and answer in different envelopes, but what\n * they DO has to be one thing: a conversion that opened a second deal for\n * one person because two callers reached the same lead through two\n * implementations is the bug this file exists to make impossible. The doors\n * own their transport; this owns the writes.\n *\n * It lives here, in the tenancy runtime, rather than beside the plugin\n * route it came from, because the console's REST layer may not import a\n * feature plugin (the app→addons boundary), while both the plugin and the\n * console may import the runtime — which already holds the capture door\n * and the owner assignment this conversion goes through.\n *\n * ## Why a server function and not four client writes\n *\n * The lead is a host document the browser may edit, and a company or a deal\n * is a client-creatable org document. What the browser cannot do is CREATE A\n * CONTACT: the contacts collection is written only by `captureHostContact`\n * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`\n * fires), because that function is the dedupe — one human, one row, found by\n * normalized address across every site in the org — and the audience-band\n * gate. A client that wrote a contact document of its own would mint a\n * second row for a person the org already holds, and would do it past the\n * band.\n *\n * Once the contact has to come from the server, the rest follows it: the\n * conversion is one act with four writes, and a caller that made three of\n * them and lost the connection before the fourth leaves a lead marked\n * qualified with no contact, or a deal pointing at a contact that was never\n * stamped on the lead. Here the order is fixed — contact, then company, then\n * deal, then the lead — and the lead is stamped LAST, so a lead that carries\n * `convertedContactId` names a contact that exists.\n *\n * ## Idempotent on the lead\n *\n * A lead already carrying `convertedContactId` answers with the ids it has\n * and creates nothing more. A double-click on the dialog's button, a retry\n * after a slow response, or a Zapier zap replaying a step must not open a\n * second deal for one person.\n *\n * ## The actor\n *\n * A signed-in member converting from the console owns what nobody else\n * claimed: their uid is the owner of last resort, written directly rather\n * than through the deliberate reassignment, because they may be staff\n * converting on a workspace's behalf and a roster check would refuse the one\n * person who is actually here. An API key is not a person and is on no\n * roster, so for an `api` actor that last step is skipped and a contact\n * nobody chose an owner for — and no rule assigned — stays unowned, which\n * the console renders honestly as unassigned. Either way the caller has\n * already been authorized by its door; nothing here asks again.\n */\n\nimport {\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { assignOwnerForCapture, notifyRecordAssigned } from './assign-contact-owner'\nimport { captureHostContact } from './capture-host-contact'\nimport { handOffLeadRecords } from './hand-off-lead'\n\n/** Who is converting, as far as the writes need to know. */\nexport interface LeadConvertActor {\n /**\n * What `createdByUid` records on every record the conversion opens, and\n * who the audit line names. A member's uid, or the literal `'api'` for a\n * key — the same attribution the REST creates stamp.\n */\n uid: string\n email?: string | null\n /**\n * `member`: a signed-in person, who becomes the owner when nobody else\n * does. `api`: a key, which cannot own a record — see the header.\n */\n kind: 'member' | 'api'\n /**\n * For an `api` actor, the key's name — what the audit line attributes the\n * conversion to, since a key has no address to be named by.\n */\n apiKeyName?: string | null\n}\n\n/** What a conversion is asked to do. Validated by the door before it gets here. */\nexport interface ConvertHostLeadInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orgId: string\n /** The org document, as the door resolved it. */\n org: Record<string, unknown>\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n actor: LeadConvertActor\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those a member actor — somebody\n * converted this person, and a record with no owner is one nobody follows\n * up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. Already normalized. */\n createCompany?: { name: string; domain: string | null } | null\n /** Open a deal in the org's default pipeline. Already normalized. */\n deal?: {\n title: string\n amountCents: number | null\n /** Lowercase ISO 4217. */\n currency: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n } | null\n}\n\n/**\n * Why a conversion did not happen. Each is a state of the data, not a fault\n * of the request — the door has already refused a malformed body — and each\n * leaves the lead unconverted so a retry after the remedy finds it where it\n * was.\n */\nexport type ConvertHostLeadRefusal =\n /** No document at `hosts/{hostId}/leads/{leadId}`. */\n | 'unknown-lead'\n /** The lead's address cannot become a contact. */\n | 'no-email'\n /**\n * The capture door produced no contact — it swallows its own failures and\n * drops a creation past a Free org's audience band. Nothing was written.\n */\n | 'contact-not-created'\n /**\n * The site holds an erasure for the lead's address (AGL-2623): the person\n * asked to be forgotten, and the capture door refuses to rebuild a record\n * for them — by a conversion as by a form. Nothing was written, and the\n * lead stays as it was; the erasure removes it when it runs.\n */\n | 'erased'\n /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */\n | 'band-full'\n /** `companyId` names no company in this organization. The contact stands. */\n | 'unknown-company'\n /** The default pipeline has no stages to open a deal in. The contact stands. */\n | 'no-stages'\n\nexport type ConvertHostLeadResult =\n | {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n }\n | { ok: false; reason: ConvertHostLeadRefusal }\n\n/** The stamp the conversion leaves on the lead — see `CrmLeadFields`. */\ntype LeadConversionStamp = Required<\n Pick<CrmLeadFields, 'status' | 'convertedContactId' | 'convertedAtMs'>\n> &\n Pick<CrmLeadFields, 'dealId' | 'companyId' | 'ownerUid'>\n\n/**\n * The stage a new deal opens in.\n *\n * The caller's choice when the pipeline has it, otherwise the first OPEN\n * stage by order — a deal created by converting a lead is by definition not\n * yet won or lost, so defaulting to a closed stage would record an outcome\n * nobody reached. A pipeline with no open stage at all falls back to its\n * first stage rather than refusing: the merchant edited their pipeline into\n * that shape, and a conversion that failed because of it would read as a bug\n * in the lead.\n */\nexport function stageForNewDeal(\n pipeline: Pick<CrmPipeline, 'stages'>,\n requestedStageId: string | undefined,\n): CrmDealStage | null {\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n if (!stages.length) return null\n const requested = requestedStageId\n ? stages.find((stage) => stage.id === requestedStageId)\n : undefined\n return requested ?? stages.find((stage) => stage.kind === 'open') ?? stages[0]\n}\n\n/** A stage's kind as the deal's denormalized status. */\nfunction dealStatusForStage(stage: CrmDealStage): CrmDealStatus {\n return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n}\n\n/**\n * Convert one lead. Throws only on infrastructure failure; every refusal\n * the data can produce is a `ConvertHostLeadResult`.\n */\nexport async function convertHostLead(\n input: ConvertHostLeadInput,\n): Promise<ConvertHostLeadResult> {\n const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input\n const requestedCompanyId = String(input.companyId ?? '').trim()\n // The lead's one home, on the org (AGL-3275). `orgId` is already resolved\n // by the caller, so this needs no second lookup to find it.\n const leadRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .doc(leadId)\n const leadSnapshot = await leadRef.get()\n if (!leadSnapshot.exists) return { ok: false, reason: 'unknown-lead' }\n const lead = (leadSnapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId) {\n return {\n ok: true,\n contactId: lead.convertedContactId,\n ...(lead.companyId ? { companyId: lead.companyId } : {}),\n ...(lead.dealId ? { dealId: lead.dealId } : {}),\n alreadyConverted: true,\n }\n }\n const email = normalizeContactEmail(lead['email'])\n if (!email) return { ok: false, reason: 'no-email' }\n\n const group = await consentGroupForSite(hostId, org)\n const visibleTo = crmScopeTokens(org, group)\n /*\n * What the CALLER may read, for the company-by-domain reuse below: their\n * group's own tokens plus `org`, exactly the set the console's listeners\n * filter on. A company outside it is one they could not open, so linking\n * a contact to it would point at a record the page then 404s on.\n */\n const readableTokens = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...consentGroupScope(group),\n ])\n /*\n * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else\n * whoever was already working the lead. Handed to the capture as the\n * facet's owner, which is what tells the capture's own assignment pass\n * to stand down — a person's choice outranks a rule. When nobody chose,\n * the facet names no owner and the pass runs the org's rules and the\n * site's default for a contact it creates (AGL-2618).\n */\n const pickedOwner = String(input.ownerUid ?? '').trim()\n const chosenOwner =\n pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '')\n const now = Date.now()\n const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined\n\n /*==========================================\n * 1. THE CONTACT — through the one door that dedupes and meters.\n *\n * `source: 'manual'` because a person converted this lead by hand, and\n * the source filter should say so; the interaction names the lead so the\n * contact's timeline can be walked back to what was captured. The facet\n * carries the stage and the owner, which is what makes this a sales\n * record rather than another form capture.\n *\n * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce\n * hands a lead's fields to the contact: phone, title, lead source, address, the\n * company as text (until step 2 links a record, whose own name then\n * replaces it), tags, and the marketing basis the lead recorded — the\n * person ticked the box on the lead, and a conversion must not be the\n * moment they lose it. Only what the lead holds is handed over; a value\n * the contact already carries is the contact's, because the capture door\n * writes a profile field only where the facet has none.\n *=========================================*/\n const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : []\n const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId))\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n // Salesforce's Lead Source travels to the contact (AGL-3298).\n ...(typeof lead.leadSource === 'string' && lead.leadSource\n ? { leadSource: lead.leadSource }\n : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {\n await notifyRecordAssigned({\n hostId,\n orgId,\n ownerUid,\n actorUid: actor.kind === 'member' ? actor.uid : null,\n record: { kind: 'contact', id: contactId },\n who: leadName || email,\n })\n }\n\n /*==========================================\n * 2. THE COMPANY — linked, found by domain, or created.\n *=========================================*/\n let companyId: string | undefined\n if (requestedCompanyId) {\n const companySnapshot = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(requestedCompanyId)\n .get()\n if (!companySnapshot.exists) return { ok: false, reason: 'unknown-company' }\n companyId = requestedCompanyId\n } else if (createCompany) {\n /*\n * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its\n * company as text, and a company the org already files under that\n * name is the account, whatever domain it carries. Then the domain,\n * the key two contacts at one business share — a second company\n * document for `acme.com` would split the account the key exists to\n * join. Only a company the caller can see counts as found — see\n * `readableTokens`.\n */\n const visibleAmong = (\n snapshots: readonly FirebaseFirestore.QueryDocumentSnapshot[],\n ): string | undefined =>\n snapshots.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })?.id\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n companyId = visibleAmong(byName.docs)\n if (!companyId && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n companyId = visibleAmong(byDomain.docs)\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...nameSearchFields(createCompany.name),\n ...(createCompany.domain ? { domain: createCompany.domain } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n companyId = created.id\n }\n }\n if (companyId) {\n /*\n * The one link writer (AGL-2613): the holder's facet field the contact\n * record reads, the queryable top-level mirror the company's contacts\n * card filters on, and the company's contacts count — planned from\n * the row as the capture left it, so a person the org already held\n * under another company is MOVED rather than counted twice.\n */\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: contactData,\n companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),\n groupId: group.groupId,\n companyId,\n })\n }\n\n /*==========================================\n * 3. THE DEAL — in the default pipeline, seeded if the org has none.\n *=========================================*/\n let dealId: string | undefined\n if (deal) {\n const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines)\n let pipelineId: string\n let pipeline: Pick<CrmPipeline, 'stages'>\n const defaults = await pipelinesRef\n .where('isDefault', '==', true)\n .limit(1)\n .get()\n if (!defaults.empty) {\n pipelineId = defaults.docs[0].id\n pipeline = defaults.docs[0].data() as CrmPipeline\n } else {\n /*\n * The deals section's own read shape, so the two agree on which\n * pipeline \"the org's pipeline\" is when none is flagged default: the\n * first by document id of a bounded window. Seeding a SECOND pipeline\n * here while one exists unflagged would leave the merchant with two\n * boards and their deals split between them.\n */\n const any = await pipelinesRef\n .orderBy(FieldPath.documentId())\n .limit(20)\n .get()\n if (!any.empty) {\n pipelineId = any.docs[0].id\n pipeline = any.docs[0].data() as CrmPipeline\n } else {\n // Exactly what the Deals section seeds: one `Sales` pipeline\n // carrying a COPY of the default stages, flagged default.\n const seeded: Omit<CrmPipeline, 'createdAt' | 'updatedAt'> = {\n name: 'Sales',\n stages: [...DEFAULT_DEAL_STAGES],\n isDefault: true,\n visibleTo,\n hostId,\n }\n const created = await pipelinesRef.add({\n ...seeded,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n pipelineId = created.id\n pipeline = seeded\n }\n }\n const stage = stageForNewDeal(pipeline, deal.stageId)\n if (!stage) return { ok: false, reason: 'no-stages' }\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n title: deal.title,\n titleLower: deal.title.toLowerCase(),\n pipelineId,\n stageId: stage.id,\n status: dealStatusForStage(stage),\n ...(deal.amountCents !== null ? { amountCents: deal.amountCents } : {}),\n currency: deal.currency,\n stageChangedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n contactId,\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n dealId = created.id\n }\n\n /*==========================================\n * 4. THE LEAD — stamped last, once everything it names exists.\n *=========================================*/\n const stamp: LeadConversionStamp = {\n status: 'qualified',\n convertedContactId: contactId,\n convertedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n }\n await leadRef.update({ ...stamp, updatedAt: FieldValue.serverTimestamp() })\n\n /*\n * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the\n * contact, and every plugin with a listener — Sequences, for an\n * enrollment naming the lead — re-points its own records. After the\n * stamp, so a listener reading the lead finds the conversion on it; it\n * never throws.\n */\n await handOffLeadRecords({\n firestore,\n orgId,\n hostId,\n leadId,\n contactId,\n email,\n by: actor.kind === 'member' ? 'member' : 'api',\n })\n\n /*\n * The audit line, from the function that did the work (AGL-2622): the\n * conversion is one act however many records it opened, so it is one\n * entry, on the lead, in the feed of the site that holds the lead. A\n * repeat call answered above with the ids it already had wrote nothing\n * and logs nothing.\n */\n await logHostActivity(\n hostId,\n {\n uid: actor.uid,\n email: actor.email ?? null,\n ...(actor.apiKeyName ? { apiKeyName: actor.apiKeyName } : {}),\n },\n 'Converted lead',\n { type: 'lead', id: leadId, name: String(lead['name'] ?? '') || email },\n )\n\n return {\n ok: true,\n contactId,\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n alreadyConverted: false,\n }\n}\n"],"names":["consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","handOffLeadRecords","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","contactSnapshot","actor","lead","firestore","hostId","orgId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","trim","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","Set","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","leadTags","Array","isArray","tags","map","leadConsent","captured","name","source","interaction","summary","refId","basis","marketingConsent","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","created","add","createdByUid","createdAt","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","deals","title","titleLower","status","amountCents","currency","stageChangedAtMs","stamp","convertedAtMs","by","apiKeyName","type"],"mappings":"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"}
@@ -1,4 +1,3 @@
1
- import { _ as _extends } from "@swc/helpers/_/_extends";
2
1
  /**
3
2
  * @license
4
3
  * Copyright 2026 Aglyn LLC
@@ -14,7 +13,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
14
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
14
  * See the License for the specific language governing permissions and
16
15
  * limitations under the License.
17
- */ import * as Aglyn from "@aglyn/aglyn/server";
16
+ */ import { readStoredComponentTree } from "@aglyn/aglyn/app-utils/load-referenced-components";
18
17
  import { firebaseAdmin } from "@aglyn/tenant-data-admin";
19
18
  import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from "@aglyn/tenant-data-admin/render-cache";
20
19
  /**
@@ -36,39 +35,21 @@ import { PUBLISHED_SITE_DATA_TTL_SECONDS, tenantDataTag, withRenderCache } from
36
35
  const firestore = firebaseAdmin.app().firestore();
37
36
  await firestore.collection('hosts').doc(hostId).collection('components').limit(200).get().then((res)=>{
38
37
  for (const docSnapshot of res.docs){
39
- var _value_props;
40
- const value = docSnapshot.data();
41
- if ((value == null ? void 0 : value.deletedAt) || !(value == null ? void 0 : value.nodes) || !(value == null ? void 0 : value.rootId)) continue;
42
38
  /*
43
- * BOTH STORED FORMS, on the hot path (AGL-1151).
39
+ * BOTH STORED FORMS, on the hot path (AGL-1151) — decoded by
40
+ * `readStoredComponentTree`, the one reading of a component document
41
+ * the on-demand email loader shares (AGL-3287), so a page and a mail
42
+ * cannot disagree about what a component publishes.
44
43
  *
45
- * A published definition is msgpack for anything promoted since
46
- * components were compressed and a plain map for everything older,
47
- * and nothing migrates them — `decodeStoredNodes` returns a map
48
- * unchanged, so one call serves both forever.
49
- *
50
- * It has to be here rather than at the caller because the failure is
51
- * silent and cached: `composeReusableComponentNodes` looks up
52
- * `nodes[rootId]` on the value below, finds nothing in a `Buffer`,
53
- * and grafts an empty wrapper — so every instance of the component
54
- * disappears from every page of the site, and the result is stored
55
- * under the render cache for the rest of its TTL.
56
- *
57
- * The decode costs no new dependency: this module already imports
58
- * `@aglyn/aglyn/server`, and the tenant's published-page CLIENT
59
- * bundle never reaches this file.
60
- */ const nodes = Aglyn.decodeStoredNodes(value.nodes);
61
- // An undecodable definition is skipped rather than grafted empty. It
62
- // is the same outcome for the page either way, but `decodeStoredNodes`
63
- // logs the reason, and a definition that silently became `{}` would
64
- // not say why the component vanished.
65
- if (!nodes) continue;
66
- data.definitions[docSnapshot.id] = _extends({
67
- rootId: value.rootId,
68
- nodes
69
- }, ((_value_props = value.props) == null ? void 0 : _value_props.length) && {
70
- props: value.props
71
- });
44
+ * It has to happen here rather than at the caller because the failure
45
+ * is silent and cached: `composeReusableComponentNodes` looks up
46
+ * `nodes[rootId]`, finds nothing in a `Buffer`, and grafts an empty
47
+ * wrapper — so every instance of the component disappears from every
48
+ * page of the site, and the result is stored under the render cache
49
+ * for the rest of its TTL. Deleted, never-published and undecodable
50
+ * definitions are skipped, exactly as before.
51
+ */ const read = readStoredComponentTree(docSnapshot.data());
52
+ if (read.ok) data.definitions[docSnapshot.id] = read.tree;
72
53
  }
73
54
  }).catch((error)=>{
74
55
  console.error(error);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/get-components.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 * 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 * up to 200 docs per render before caching. Component publishes announce\n * themselves through the console revalidate route, which busts\n * `tenant-data:{hostId}`; The TTL is only the backstop for writes that never announce themselves — see PUBLISHED_SITE_DATA_TTL_SECONDS.\n */\nconst COMPONENTS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/**\n * Fetches the host's reusable component definitions keyed by id, in the\n * shape `composeReusableComponentNodes` consumes. Fail-open: on error an\n * empty map is returned (instances render as empty wrappers, the page still\n * serves).\n */\nasync function readComponents(hostId: Aglyn.HostUid) {\n const data = {\n definitions: {} as Record<string, Aglyn.ReusableComponentTree>,\n error: null as unknown,\n }\n const firestore = firebaseAdmin.app().firestore()\n\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('components')\n .limit(200)\n .get()\n .then((res) => {\n for (const docSnapshot of res.docs) {\n const value = docSnapshot.data() as Aglyn.AglynHostComponent\n if (value?.deletedAt || !value?.nodes || !value?.rootId) continue\n /*\n * BOTH STORED FORMS, on the hot path (AGL-1151).\n *\n * A published definition is msgpack for anything promoted since\n * components were compressed and a plain map for everything older,\n * and nothing migrates them — `decodeStoredNodes` returns a map\n * unchanged, so one call serves both forever.\n *\n * It has to be here rather than at the caller because the failure is\n * silent and cached: `composeReusableComponentNodes` looks up\n * `nodes[rootId]` on the value below, finds nothing in a `Buffer`,\n * and grafts an empty wrapper — so every instance of the component\n * disappears from every page of the site, and the result is stored\n * under the render cache for the rest of its TTL.\n *\n * The decode costs no new dependency: this module already imports\n * `@aglyn/aglyn/server`, and the tenant's published-page CLIENT\n * bundle never reaches this file.\n */\n const nodes = Aglyn.decodeStoredNodes<\n Aglyn.ReusableComponentTree['nodes']\n >(value.nodes)\n // An undecodable definition is skipped rather than grafted empty. It\n // is the same outcome for the page either way, but `decodeStoredNodes`\n // logs the reason, and a definition that silently became `{}` would\n // not say why the component vanished.\n if (!nodes) continue\n data.definitions[docSnapshot.id] = {\n rootId: value.rootId,\n nodes,\n // Declared props (AGL-1247): without these the graft leaves every\n // `{{prop.*}}` token unresolved on the published page.\n ...(value.props?.length && { props: value.props }),\n }\n }\n })\n .catch((error) => {\n console.error(error)\n data.error = error\n })\n\n return data\n}\n\nexport async function getComponents(options: { hostId: Aglyn.HostUid }) {\n const { hostId } = options\n try {\n return await withRenderCache({\n key: ['tenant-components', hostId as string],\n revalidate: COMPONENTS_TTL_SECONDS,\n tags: [tenantDataTag(hostId as string)],\n read: () => readComponents(hostId),\n // A failed read keeps its fail-open empty map per-request only.\n store: (value) => !value.error,\n })\n } catch (error) {\n console.error(error)\n return readComponents(hostId)\n }\n}\n\nexport default getComponents\n"],"names":["Aglyn","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","COMPONENTS_TTL_SECONDS","readComponents","hostId","data","definitions","error","firestore","app","collection","doc","limit","get","then","res","docSnapshot","docs","value","deletedAt","nodes","rootId","decodeStoredNodes","id","props","length","catch","console","getComponents","options","key","revalidate","tags","read","store"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAE9C;;;;;CAKC,GACD,MAAMC,yBAAyBH;AAE/B;;;;;CAKC,GACD,eAAeI,eAAeC,MAAqB;IACjD,MAAMC,OAAO;QACXC,aAAa,CAAC;QACdC,OAAO;IACT;IACA,MAAMC,YAAYV,cAAcW,GAAG,GAAGD,SAAS;IAE/C,MAAMA,UACHE,UAAU,CAAC,SACXC,GAAG,CAACP,QACJM,UAAU,CAAC,cACXE,KAAK,CAAC,KACNC,GAAG,GACHC,IAAI,CAAC,CAACC;QACL,KAAK,MAAMC,eAAeD,IAAIE,IAAI,CAAE;gBAmC5BC;YAlCN,MAAMA,QAAQF,YAAYX,IAAI;YAC9B,IAAIa,CAAAA,yBAAAA,MAAOC,SAAS,KAAI,EAACD,yBAAAA,MAAOE,KAAK,KAAI,EAACF,yBAAAA,MAAOG,MAAM,GAAE;YACzD;;;;;;;;;;;;;;;;;;SAkBC,GACD,MAAMD,QAAQvB,MAAMyB,iBAAiB,CAEnCJ,MAAME,KAAK;YACb,qEAAqE;YACrE,uEAAuE;YACvE,oEAAoE;YACpE,sCAAsC;YACtC,IAAI,CAACA,OAAO;YACZf,KAAKC,WAAW,CAACU,YAAYO,EAAE,CAAC,GAAG;gBACjCF,QAAQH,MAAMG,MAAM;gBACpBD;eAGIF,EAAAA,eAAAA,MAAMM,KAAK,qBAAXN,aAAaO,MAAM,KAAI;gBAAED,OAAON,MAAMM,KAAK;YAAC;QAEpD;IACF,GACCE,KAAK,CAAC,CAACnB;QACNoB,QAAQpB,KAAK,CAACA;QACdF,KAAKE,KAAK,GAAGA;IACf;IAEF,OAAOF;AACT;AAEA,OAAO,eAAeuB,cAAcC,OAAkC;IACpE,MAAM,EAAEzB,MAAM,EAAE,GAAGyB;IACnB,IAAI;QACF,OAAO,MAAM5B,gBAAgB;YAC3B6B,KAAK;gBAAC;gBAAqB1B;aAAiB;YAC5C2B,YAAY7B;YACZ8B,MAAM;gBAAChC,cAAcI;aAAkB;YACvC6B,MAAM,IAAM9B,eAAeC;YAC3B,gEAAgE;YAChE8B,OAAO,CAAChB,QAAU,CAACA,MAAMX,KAAK;QAChC;IACF,EAAE,OAAOA,OAAO;QACdoB,QAAQpB,KAAK,CAACA;QACd,OAAOJ,eAAeC;IACxB;AACF;AAEA,eAAewB,cAAa"}
1
+ {"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/get-components.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 * as Aglyn from '@aglyn/aglyn/server'\nimport {\n readStoredComponentTree,\n type StoredComponentDocument,\n} from '@aglyn/aglyn/app-utils/load-referenced-components'\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 * up to 200 docs per render before caching. Component publishes announce\n * themselves through the console revalidate route, which busts\n * `tenant-data:{hostId}`; The TTL is only the backstop for writes that never announce themselves — see PUBLISHED_SITE_DATA_TTL_SECONDS.\n */\nconst COMPONENTS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/**\n * Fetches the host's reusable component definitions keyed by id, in the\n * shape `composeReusableComponentNodes` consumes. Fail-open: on error an\n * empty map is returned (instances render as empty wrappers, the page still\n * serves).\n */\nasync function readComponents(hostId: Aglyn.HostUid) {\n const data = {\n definitions: {} as Record<string, Aglyn.ReusableComponentTree>,\n error: null as unknown,\n }\n const firestore = firebaseAdmin.app().firestore()\n\n await firestore\n .collection('hosts')\n .doc(hostId)\n .collection('components')\n .limit(200)\n .get()\n .then((res) => {\n for (const docSnapshot of res.docs) {\n /*\n * BOTH STORED FORMS, on the hot path (AGL-1151) — decoded by\n * `readStoredComponentTree`, the one reading of a component document\n * the on-demand email loader shares (AGL-3287), so a page and a mail\n * cannot disagree about what a component publishes.\n *\n * It has to happen here rather than at the caller because the failure\n * is silent and cached: `composeReusableComponentNodes` looks up\n * `nodes[rootId]`, finds nothing in a `Buffer`, and grafts an empty\n * wrapper — so every instance of the component disappears from every\n * page of the site, and the result is stored under the render cache\n * for the rest of its TTL. Deleted, never-published and undecodable\n * definitions are skipped, exactly as before.\n */\n const read = readStoredComponentTree(\n docSnapshot.data() as StoredComponentDocument | undefined,\n )\n if (read.ok) data.definitions[docSnapshot.id] = read.tree\n }\n })\n .catch((error) => {\n console.error(error)\n data.error = error\n })\n\n return data\n}\n\nexport async function getComponents(options: { hostId: Aglyn.HostUid }) {\n const { hostId } = options\n try {\n return await withRenderCache({\n key: ['tenant-components', hostId as string],\n revalidate: COMPONENTS_TTL_SECONDS,\n tags: [tenantDataTag(hostId as string)],\n read: () => readComponents(hostId),\n // A failed read keeps its fail-open empty map per-request only.\n store: (value) => !value.error,\n })\n } catch (error) {\n console.error(error)\n return readComponents(hostId)\n }\n}\n\nexport default getComponents\n"],"names":["readStoredComponentTree","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","COMPONENTS_TTL_SECONDS","readComponents","hostId","data","definitions","error","firestore","app","collection","doc","limit","get","then","res","docSnapshot","docs","read","ok","id","tree","catch","console","getComponents","options","key","revalidate","tags","store","value"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAGD,SACEA,uBAAuB,QAElB,oDAAmD;AAC1D,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAE9C;;;;;CAKC,GACD,MAAMC,yBAAyBH;AAE/B;;;;;CAKC,GACD,eAAeI,eAAeC,MAAqB;IACjD,MAAMC,OAAO;QACXC,aAAa,CAAC;QACdC,OAAO;IACT;IACA,MAAMC,YAAYV,cAAcW,GAAG,GAAGD,SAAS;IAE/C,MAAMA,UACHE,UAAU,CAAC,SACXC,GAAG,CAACP,QACJM,UAAU,CAAC,cACXE,KAAK,CAAC,KACNC,GAAG,GACHC,IAAI,CAAC,CAACC;QACL,KAAK,MAAMC,eAAeD,IAAIE,IAAI,CAAE;YAClC;;;;;;;;;;;;;SAaC,GACD,MAAMC,OAAOrB,wBACXmB,YAAYX,IAAI;YAElB,IAAIa,KAAKC,EAAE,EAAEd,KAAKC,WAAW,CAACU,YAAYI,EAAE,CAAC,GAAGF,KAAKG,IAAI;QAC3D;IACF,GACCC,KAAK,CAAC,CAACf;QACNgB,QAAQhB,KAAK,CAACA;QACdF,KAAKE,KAAK,GAAGA;IACf;IAEF,OAAOF;AACT;AAEA,OAAO,eAAemB,cAAcC,OAAkC;IACpE,MAAM,EAAErB,MAAM,EAAE,GAAGqB;IACnB,IAAI;QACF,OAAO,MAAMxB,gBAAgB;YAC3ByB,KAAK;gBAAC;gBAAqBtB;aAAiB;YAC5CuB,YAAYzB;YACZ0B,MAAM;gBAAC5B,cAAcI;aAAkB;YACvCc,MAAM,IAAMf,eAAeC;YAC3B,gEAAgE;YAChEyB,OAAO,CAACC,QAAU,CAACA,MAAMvB,KAAK;QAChC;IACF,EAAE,OAAOA,OAAO;QACdgB,QAAQhB,KAAK,CAACA;QACd,OAAOJ,eAAeC;IACxB;AACF;AAEA,eAAeoB,cAAa"}