@aglyn/tenant-runtime 1.0.0-beta.198 → 1.0.0-beta.200
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 +3 -3
- package/src/lib/assign-contact-owner.js +3 -1
- package/src/lib/assign-contact-owner.js.map +1 -1
- package/src/lib/associate-company-by-domain.js +7 -4
- package/src/lib/associate-company-by-domain.js.map +1 -1
- package/src/lib/convert-host-lead.js +10 -6
- package/src/lib/convert-host-lead.js.map +1 -1
- package/src/lib/get-collection-content.d.ts +41 -4
- package/src/lib/get-collection-content.js +98 -50
- package/src/lib/get-collection-content.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aglyn/tenant-runtime",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.200",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"homepage": "https://aglyn.com",
|
|
6
6
|
"repository": {
|
|
@@ -25,8 +25,8 @@
|
|
|
25
25
|
"./package.json": "./package.json"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@aglyn/aglyn": "1.0.0-beta.
|
|
29
|
-
"@aglyn/tenant-data-admin": "1.0.0-beta.
|
|
28
|
+
"@aglyn/aglyn": "1.0.0-beta.200",
|
|
29
|
+
"@aglyn/tenant-data-admin": "1.0.0-beta.200",
|
|
30
30
|
"@swc/helpers": "0.5.23"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
@@ -15,7 +15,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { assignmentRuleMatches, consentGroupForHost, contactFacetPath, CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH, personKey, readContactFacet, readCrmAssignmentSettings, roundRobinOrder } from "@aglyn/aglyn/server";
|
|
18
|
-
import { firebaseAdmin, getOrgForHost, notifyUsers } from "@aglyn/tenant-data-admin";
|
|
18
|
+
import { firebaseAdmin, getOrgForHost, notifyUsers, restampCrmListFieldsAt } from "@aglyn/tenant-data-admin";
|
|
19
19
|
import { FieldValue } from "firebase-admin/firestore";
|
|
20
20
|
/** The words the run history and the log give each refusal. */ export const OWNER_ASSIGNMENT_REFUSALS = {
|
|
21
21
|
'no-org': 'this site has no organization',
|
|
@@ -240,6 +240,8 @@ async function assignOwner(policy) {
|
|
|
240
240
|
});
|
|
241
241
|
});
|
|
242
242
|
if (verdict.outcome !== 'assigned') return verdict;
|
|
243
|
+
// The owner is what the Contacts list filters by (AGL-3321).
|
|
244
|
+
await restampCrmListFieldsAt(contactRef, 'contacts');
|
|
243
245
|
if (!verdict.leadMirrored && leadRef && policy.kind === 'capture' && LEAD_FILING_SOURCES.has(policy.input.source)) {
|
|
244
246
|
void mirrorOwnerOntoLeadLater(leadRef, verdict.ownerUid);
|
|
245
247
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/assign-contact-owner.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assignmentRuleMatches,\n consentGroupForHost,\n contactFacetPath,\n type ContactSource,\n CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH,\n type CrmAssignmentTarget,\n personKey,\n readContactFacet,\n readCrmAssignmentSettings,\n roundRobinOrder,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n notifyUsers,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * WHO A NEW RECORD BELONGS TO — the one place an owner is decided on the\n * server (AGL-2618).\n *\n * Two entry points over one transaction. `assignOwnerForCapture` is the\n * capture door's pass: it runs the org's assignment rules in order, falls\n * back to the capturing site's default owner, and touches only a record\n * that has NO owner — a door that named one (the console's drawer, an\n * import column, a conversion with a picked owner) has expressed a person's\n * choice. `reassignContactOwner` is the automation's: an author who put\n * \"assign an owner\" on a stage change means to reassign, and the step hands\n * this either a member or the rotation.\n *\n * ## One transaction, because the pointer is shared\n *\n * The round-robin pool's pointer lives on the org document, and two\n * captures landing together must take two different members. Firestore\n * transactions serialize on what they read, so the org document, the\n * contact and the roster documents are all read inside the transaction and\n * the pointer is advanced in the same commit that writes the owner: a\n * concurrent capture that read the same pointer retries and reads the moved\n * one. The alternative — read the pointer, decide, then write — is exactly\n * the read-then-write shape that lets two captures pick the same person.\n *\n * ## The roster is checked, once per member per attempt\n *\n * A rule may name a member who has since left, and a pool may hold one. A\n * rule naming nobody on the roster is passed over rather than stopping the\n * pass — the next rule may well assign — and a pool member who is gone is\n * skipped for the next in the order. `orgs/{orgId}/members/{uid}` is keyed\n * by uid, so the check is one document read per candidate, and only the\n * roster is consulted: a project-level Auth lookup would admit people who\n * are not on this organization at all (AGL-1122).\n *\n * ## The lead follows the contact\n *\n * The capture doors that file a lead (`addHostLead`: a form routed to leads,\n * a sign-up, a booking) key it by `personKey(email)`, so the lead's document\n * is addressable from the capture alone. When it exists it is given the same\n * owner in the same transaction; two doors write the lead AFTER the contact\n * they `void`ed, so for those sources the mirror is retried a few times over\n * the next seconds, detached, and the person's Leads list shows the owner\n * as soon as the lead is there.\n *\n * ## The owner is told, unless they chose themselves\n *\n * One console notification per assignment, for the record the recipient\n * will open: the lead when the site holds one for this person, the contact\n * otherwise. Not when the actor IS the new owner — a rep who converts a\n * lead into their own contact has not been handed anything.\n *\n * ## Never rejects\n *\n * The capture has already succeeded and a form submission must not fail\n * because the CRM could not decide who follows up; a failure is logged and\n * answered as `none`, and the record is exactly as assignable by hand as it\n * was before this existed. The automation executor turns a `none` into the\n * step's recorded error.\n */\n\nexport interface OwnerAssignmentInput {\n hostId: string\n /** `orgs/{orgId}/contacts/{contactId}` — the record the owner is written on. */\n contactId: string\n /** The contact's address, which keys the lead and names the person in the notification. */\n email: string\n /**\n * The signed-in person making the assignment, when there is one. Never\n * notified about their own choice; absent for a capture and an automation.\n */\n actorUid?: string | null\n}\n\nexport interface CaptureAssignmentInput extends OwnerAssignmentInput {\n source: ContactSource\n /** The form the capture came through, for a rule conditioned on it. */\n formId?: string | null\n /** The tags the capture carried; the contact's own are read from the facet. */\n tags?: readonly string[]\n}\n\nexport interface ReassignmentInput extends OwnerAssignmentInput {\n assign: CrmAssignmentTarget\n}\n\n/** Why an assignment assigned nobody — the automation reports these verbatim. */\nexport type OwnerAssignmentRefusal =\n | 'no-org'\n | 'no-contact'\n | 'no-rule'\n | 'empty-pool'\n | 'not-a-member'\n | 'failed'\n\n/** What decided an assignment: a rule, the site default, the step's member, or the rotation. */\nexport type OwnerAssignedBy = 'rule' | 'default' | 'member' | 'roundRobin'\n\nexport type OwnerAssignment =\n | {\n outcome: 'assigned'\n ownerUid: string\n by: OwnerAssignedBy\n /** The rule that decided it, when one did. */\n ruleId?: string\n /** The lead document existed and now carries the owner too. */\n leadMirrored: boolean\n /** A notification was fanned out to the new owner. */\n notified: boolean\n }\n /** The record already had this owner (a capture), or the same one was named again (a reassignment). */\n | { outcome: 'unchanged'; ownerUid: string }\n | { outcome: 'none'; reason: OwnerAssignmentRefusal }\n\n/** The words the run history and the log give each refusal. */\nexport const OWNER_ASSIGNMENT_REFUSALS: Record<OwnerAssignmentRefusal, string> = {\n 'no-org': 'this site has no organization',\n 'no-contact': 'the contact no longer exists',\n 'no-rule': 'no assignment rule matched and the site has no default owner',\n 'empty-pool': 'the round-robin pool has nobody on the roster in it',\n 'not-a-member': 'the owner named is not on the team',\n failed: 'the owner could not be assigned',\n}\n\n/**\n * The doors whose lead lands after the contact they `void`ed — the ones the\n * mirror is worth retrying for. An order, an import, a manual add and an\n * API call file no lead, and a retry for them would be reads for nothing.\n */\nconst LEAD_FILING_SOURCES: ReadonlySet<ContactSource> = new Set([\n 'form',\n 'member',\n 'booking',\n])\n\n/** How long the detached lead mirror waits between its looks, in order. */\nconst LEAD_MIRROR_RETRY_MS: readonly number[] = [1_000, 2_000, 3_000]\n\ntype Policy =\n | { kind: 'capture'; input: CaptureAssignmentInput }\n | { kind: 'reassign'; input: ReassignmentInput }\n\ninterface Decision {\n ownerUid: string\n by: OwnerAssignedBy\n ruleId?: string\n /** The pointer moves only when the rotation chose. */\n advancePointer: boolean\n}\n\n/**\n * The console path the owner's notification opens, in the `/{hostDocId}/rest`\n * shape every host notification uses — the console rewrites it to\n * `/{orgSlug}/hosts/{subdomain}/rest` when the link is followed, which is\n * why `hostId` and `orgId` travel on the document. The same spelling the\n * CRM's task notification uses; the hub's own route builder lives in the\n * plugin, which this layer cannot import.\n */\nfunction recordLink(hostId: string, record: AssignedRecord): string {\n const section = record.kind === 'contact' ? 'contacts' : 'leads'\n return `/${hostId}/crm/${section}/${encodeURIComponent(record.id)}`\n}\n\n/** The record an assignment notification opens. */\nexport type AssignedRecord =\n | { kind: 'contact'; id: string }\n | { kind: 'lead'; id: string }\n\n/**\n * Tells a member a record is now theirs — one console notification, typed\n * by the record it opens — unless the member handed it to themselves.\n * Shared by the pass above and by the routes where a person picks the\n * owner outright (a lead conversion naming a colleague), so every server\n * path that writes `ownerUid` says so in the same words. Never throws;\n * the fan-out swallows its own failures.\n */\nexport async function notifyRecordAssigned(input: {\n hostId: string\n orgId: string\n ownerUid: string\n actorUid?: string | null\n record: AssignedRecord\n /** How the person reads in the message: their name, else their address. */\n who: string\n}): Promise<boolean> {\n if (!input.ownerUid || input.ownerUid === (input.actorUid ?? '')) return false\n const lead = input.record.kind === 'lead'\n await notifyUsers([input.ownerUid], {\n type: lead ? 'content.leadAssigned' : 'content.contactAssigned',\n title: lead ? 'Lead assigned to you' : 'Contact assigned to you',\n body: input.who,\n link: recordLink(input.hostId, input.record),\n orgId: input.orgId,\n hostId: input.hostId,\n })\n return true\n}\n\nasync function assignOwner(policy: Policy): Promise<OwnerAssignment> {\n const { input } = policy\n try {\n const resolved = await getOrgForHost(input.hostId)\n if (!resolved) return { outcome: 'none', reason: 'no-org' }\n const { orgId } = resolved\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contactRef = orgRef.collection('contacts').doc(input.contactId)\n const membersRef = orgRef.collection('members')\n const leadKey = personKey(input.email)\n // Beside the contact on the org, keyed by the same address (AGL-3275).\n const leadRef = leadKey ? orgRef.collection('leads').doc(leadKey) : null\n\n let contactName = ''\n const verdict = await firestore.runTransaction(\n async (tx): Promise<OwnerAssignment> => {\n /*\n * ALL READS BEFORE THE WRITE, which Firestore requires. The org\n * document is read here rather than trusted from `getOrgForHost`,\n * because the pointer it carries is the one thing a concurrent\n * capture may have moved, and the rules and the pool are read from\n * the same snapshot so the decision and the pointer agree.\n */\n const [orgSnapshot, contactSnapshot, leadSnapshot] = await Promise.all([\n tx.get(orgRef),\n tx.get(contactRef),\n leadRef ? tx.get(leadRef) : Promise.resolve(null),\n ])\n if (!contactSnapshot.exists) {\n return { outcome: 'none', reason: 'no-contact' }\n }\n const org = (orgSnapshot.data() ?? {}) as Record<string, unknown>\n const contact = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n contactName = String(contact['name'] ?? '')\n const group = consentGroupForHost(org, input.hostId)\n const facet = readContactFacet(contact, group.groupId)\n const current = typeof facet.ownerUid === 'string' ? facet.ownerUid : ''\n if (policy.kind === 'capture' && current) {\n return { outcome: 'unchanged', ownerUid: current }\n }\n const settings = readCrmAssignmentSettings(org)\n\n // One read per candidate per attempt; a retried transaction reads\n // afresh, as it must.\n const onRoster = new Map<string, Promise<boolean>>()\n const isMember = (uid: string): Promise<boolean> => {\n let known = onRoster.get(uid)\n if (!known) {\n known = tx.get(membersRef.doc(uid)).then((snapshot) => snapshot.exists)\n onRoster.set(uid, known)\n }\n return known\n }\n const fromPool = async (): Promise<string | null> => {\n for (const uid of roundRobinOrder(\n settings.pool.memberUids,\n settings.pool.lastAssignedUid,\n )) {\n if (await isMember(uid)) return uid\n }\n return null\n }\n const resolveTarget = async (\n target: CrmAssignmentTarget,\n ): Promise<{ uid: string; advancePointer: boolean } | null> => {\n if ('roundRobin' in target) {\n const uid = await fromPool()\n return uid ? { uid, advancePointer: true } : null\n }\n return (await isMember(target.memberUid))\n ? { uid: target.memberUid, advancePointer: false }\n : null\n }\n\n let decision: Decision | null = null\n let refusal: OwnerAssignmentRefusal = 'no-rule'\n if (policy.kind === 'capture') {\n const capture = {\n source: policy.input.source,\n email: input.email,\n formId: policy.input.formId ?? null,\n tags: [...(policy.input.tags ?? []), ...(facet.tags ?? [])],\n }\n for (const rule of settings.rules) {\n if (!assignmentRuleMatches(rule.when, capture)) continue\n const hit = await resolveTarget(rule.assign)\n if (hit) {\n decision = { ...hit, ownerUid: hit.uid, by: 'rule', ruleId: rule.id }\n break\n }\n }\n const fallback = settings.hostDefaultOwners[input.hostId]\n if (!decision && fallback && (await isMember(fallback))) {\n decision = { ownerUid: fallback, by: 'default', advancePointer: false }\n }\n } else {\n const { assign } = policy.input\n const hit = await resolveTarget(assign)\n if (hit) {\n decision = {\n ...hit,\n ownerUid: hit.uid,\n by: 'roundRobin' in assign ? 'roundRobin' : 'member',\n }\n } else {\n refusal = 'roundRobin' in assign ? 'empty-pool' : 'not-a-member'\n }\n }\n if (!decision) return { outcome: 'none', reason: refusal }\n if (decision.ownerUid === current) {\n return { outcome: 'unchanged', ownerUid: current }\n }\n\n tx.update(contactRef, {\n [contactFacetPath(group.groupId, 'ownerUid')]: decision.ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n if (decision.advancePointer) {\n tx.update(orgRef, {\n [CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH]: decision.ownerUid,\n })\n }\n /*\n * A lead somebody already assigned by hand keeps its owner on a\n * capture — the contact was new, the lead may not have been — and\n * follows the contact on a deliberate reassignment.\n */\n const leadOwner = String(leadSnapshot?.get('ownerUid') ?? '')\n const leadMirrored = Boolean(\n leadSnapshot?.exists && (policy.kind === 'reassign' || !leadOwner),\n )\n if (leadMirrored && leadRef) {\n tx.update(leadRef, {\n ownerUid: decision.ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n return {\n outcome: 'assigned',\n ownerUid: decision.ownerUid,\n by: decision.by,\n ...(decision.ruleId ? { ruleId: decision.ruleId } : {}),\n leadMirrored,\n notified: false,\n }\n },\n )\n if (verdict.outcome !== 'assigned') return verdict\n\n if (\n !verdict.leadMirrored &&\n leadRef &&\n policy.kind === 'capture' &&\n LEAD_FILING_SOURCES.has(policy.input.source)\n ) {\n void mirrorOwnerOntoLeadLater(leadRef, verdict.ownerUid)\n }\n\n const notified = await notifyRecordAssigned({\n hostId: input.hostId,\n orgId,\n ownerUid: verdict.ownerUid,\n actorUid: input.actorUid,\n record:\n verdict.leadMirrored && leadKey\n ? { kind: 'lead', id: leadKey }\n : { kind: 'contact', id: input.contactId },\n who: contactName || input.email,\n })\n return { ...verdict, notified }\n } catch (error) {\n console.error(\n 'assignContactOwner failed',\n input.hostId,\n input.contactId,\n error,\n )\n return { outcome: 'none', reason: 'failed' }\n }\n}\n\n/**\n * The detached second look for a lead that lands after the contact.\n *\n * Bounded, and only ever ADDS an owner: a lead that arrived with one (a\n * person assigned it in the meantime) is left alone, and a lead that never\n * arrives costs three reads over six seconds and nothing else. Detached\n * because the capture must not hold `contactCreated` back for it — an\n * automation welcoming the contact has nothing to do with the lead's owner\n * column.\n */\nasync function mirrorOwnerOntoLeadLater(\n leadRef: FirebaseFirestore.DocumentReference,\n ownerUid: string,\n delaysMs: readonly number[] = LEAD_MIRROR_RETRY_MS,\n): Promise<boolean> {\n for (const delay of delaysMs) {\n await new Promise((resolve) => setTimeout(resolve, delay))\n try {\n const lead = await leadRef.get()\n if (!lead.exists) continue\n if (String(lead.get('ownerUid') ?? '')) return false\n await leadRef.update({\n ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n console.error('assignContactOwner lead mirror failed', leadRef.path, error)\n return false\n }\n }\n return false\n}\n\n/**\n * The capture door's pass: the org's rules in order, then the capturing\n * site's default owner, on a record that has no owner yet. See the module\n * comment for the whole of it.\n */\nexport function assignOwnerForCapture(\n input: CaptureAssignmentInput,\n): Promise<OwnerAssignment> {\n return assignOwner({ kind: 'capture', input })\n}\n\n/**\n * The deliberate assignment — the `assignContactOwner` step, and a person\n * handing a record to somebody: a member by uid (verified on the roster) or\n * the next in the round-robin pool, overwriting whoever held it. Naming the\n * owner the record already has changes nothing and tells nobody.\n */\nexport function reassignContactOwner(\n input: ReassignmentInput,\n): Promise<OwnerAssignment> {\n return assignOwner({ kind: 'reassign', input })\n}\n\nexport default assignOwnerForCapture\n"],"names":["assignmentRuleMatches","consentGroupForHost","contactFacetPath","CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH","personKey","readContactFacet","readCrmAssignmentSettings","roundRobinOrder","firebaseAdmin","getOrgForHost","notifyUsers","FieldValue","OWNER_ASSIGNMENT_REFUSALS","failed","LEAD_FILING_SOURCES","Set","LEAD_MIRROR_RETRY_MS","recordLink","hostId","record","section","kind","encodeURIComponent","id","notifyRecordAssigned","input","ownerUid","actorUid","lead","type","title","body","who","link","orgId","assignOwner","policy","resolved","outcome","reason","firestore","app","orgRef","collection","doc","contactRef","contactId","membersRef","leadKey","email","leadRef","contactName","verdict","runTransaction","tx","orgSnapshot","contactSnapshot","contact","leadSnapshot","Promise","all","get","resolve","exists","org","data","String","group","facet","groupId","current","settings","onRoster","Map","isMember","uid","known","then","snapshot","set","fromPool","pool","memberUids","lastAssignedUid","resolveTarget","target","advancePointer","memberUid","decision","refusal","capture","source","formId","tags","rule","rules","when","hit","assign","by","ruleId","fallback","hostDefaultOwners","update","updatedAt","serverTimestamp","leadOwner","leadMirrored","Boolean","notified","has","mirrorOwnerOntoLeadLater","error","console","delaysMs","delay","setTimeout","path","assignOwnerForCapture","reassignContactOwner"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/assign-contact-owner.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n assignmentRuleMatches,\n consentGroupForHost,\n contactFacetPath,\n type ContactSource,\n CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH,\n type CrmAssignmentTarget,\n personKey,\n readContactFacet,\n readCrmAssignmentSettings,\n roundRobinOrder,\n} from '@aglyn/aglyn/server'\nimport {\n firebaseAdmin,\n getOrgForHost,\n notifyUsers,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/**\n * WHO A NEW RECORD BELONGS TO — the one place an owner is decided on the\n * server (AGL-2618).\n *\n * Two entry points over one transaction. `assignOwnerForCapture` is the\n * capture door's pass: it runs the org's assignment rules in order, falls\n * back to the capturing site's default owner, and touches only a record\n * that has NO owner — a door that named one (the console's drawer, an\n * import column, a conversion with a picked owner) has expressed a person's\n * choice. `reassignContactOwner` is the automation's: an author who put\n * \"assign an owner\" on a stage change means to reassign, and the step hands\n * this either a member or the rotation.\n *\n * ## One transaction, because the pointer is shared\n *\n * The round-robin pool's pointer lives on the org document, and two\n * captures landing together must take two different members. Firestore\n * transactions serialize on what they read, so the org document, the\n * contact and the roster documents are all read inside the transaction and\n * the pointer is advanced in the same commit that writes the owner: a\n * concurrent capture that read the same pointer retries and reads the moved\n * one. The alternative — read the pointer, decide, then write — is exactly\n * the read-then-write shape that lets two captures pick the same person.\n *\n * ## The roster is checked, once per member per attempt\n *\n * A rule may name a member who has since left, and a pool may hold one. A\n * rule naming nobody on the roster is passed over rather than stopping the\n * pass — the next rule may well assign — and a pool member who is gone is\n * skipped for the next in the order. `orgs/{orgId}/members/{uid}` is keyed\n * by uid, so the check is one document read per candidate, and only the\n * roster is consulted: a project-level Auth lookup would admit people who\n * are not on this organization at all (AGL-1122).\n *\n * ## The lead follows the contact\n *\n * The capture doors that file a lead (`addHostLead`: a form routed to leads,\n * a sign-up, a booking) key it by `personKey(email)`, so the lead's document\n * is addressable from the capture alone. When it exists it is given the same\n * owner in the same transaction; two doors write the lead AFTER the contact\n * they `void`ed, so for those sources the mirror is retried a few times over\n * the next seconds, detached, and the person's Leads list shows the owner\n * as soon as the lead is there.\n *\n * ## The owner is told, unless they chose themselves\n *\n * One console notification per assignment, for the record the recipient\n * will open: the lead when the site holds one for this person, the contact\n * otherwise. Not when the actor IS the new owner — a rep who converts a\n * lead into their own contact has not been handed anything.\n *\n * ## Never rejects\n *\n * The capture has already succeeded and a form submission must not fail\n * because the CRM could not decide who follows up; a failure is logged and\n * answered as `none`, and the record is exactly as assignable by hand as it\n * was before this existed. The automation executor turns a `none` into the\n * step's recorded error.\n */\n\nexport interface OwnerAssignmentInput {\n hostId: string\n /** `orgs/{orgId}/contacts/{contactId}` — the record the owner is written on. */\n contactId: string\n /** The contact's address, which keys the lead and names the person in the notification. */\n email: string\n /**\n * The signed-in person making the assignment, when there is one. Never\n * notified about their own choice; absent for a capture and an automation.\n */\n actorUid?: string | null\n}\n\nexport interface CaptureAssignmentInput extends OwnerAssignmentInput {\n source: ContactSource\n /** The form the capture came through, for a rule conditioned on it. */\n formId?: string | null\n /** The tags the capture carried; the contact's own are read from the facet. */\n tags?: readonly string[]\n}\n\nexport interface ReassignmentInput extends OwnerAssignmentInput {\n assign: CrmAssignmentTarget\n}\n\n/** Why an assignment assigned nobody — the automation reports these verbatim. */\nexport type OwnerAssignmentRefusal =\n | 'no-org'\n | 'no-contact'\n | 'no-rule'\n | 'empty-pool'\n | 'not-a-member'\n | 'failed'\n\n/** What decided an assignment: a rule, the site default, the step's member, or the rotation. */\nexport type OwnerAssignedBy = 'rule' | 'default' | 'member' | 'roundRobin'\n\nexport type OwnerAssignment =\n | {\n outcome: 'assigned'\n ownerUid: string\n by: OwnerAssignedBy\n /** The rule that decided it, when one did. */\n ruleId?: string\n /** The lead document existed and now carries the owner too. */\n leadMirrored: boolean\n /** A notification was fanned out to the new owner. */\n notified: boolean\n }\n /** The record already had this owner (a capture), or the same one was named again (a reassignment). */\n | { outcome: 'unchanged'; ownerUid: string }\n | { outcome: 'none'; reason: OwnerAssignmentRefusal }\n\n/** The words the run history and the log give each refusal. */\nexport const OWNER_ASSIGNMENT_REFUSALS: Record<OwnerAssignmentRefusal, string> = {\n 'no-org': 'this site has no organization',\n 'no-contact': 'the contact no longer exists',\n 'no-rule': 'no assignment rule matched and the site has no default owner',\n 'empty-pool': 'the round-robin pool has nobody on the roster in it',\n 'not-a-member': 'the owner named is not on the team',\n failed: 'the owner could not be assigned',\n}\n\n/**\n * The doors whose lead lands after the contact they `void`ed — the ones the\n * mirror is worth retrying for. An order, an import, a manual add and an\n * API call file no lead, and a retry for them would be reads for nothing.\n */\nconst LEAD_FILING_SOURCES: ReadonlySet<ContactSource> = new Set([\n 'form',\n 'member',\n 'booking',\n])\n\n/** How long the detached lead mirror waits between its looks, in order. */\nconst LEAD_MIRROR_RETRY_MS: readonly number[] = [1_000, 2_000, 3_000]\n\ntype Policy =\n | { kind: 'capture'; input: CaptureAssignmentInput }\n | { kind: 'reassign'; input: ReassignmentInput }\n\ninterface Decision {\n ownerUid: string\n by: OwnerAssignedBy\n ruleId?: string\n /** The pointer moves only when the rotation chose. */\n advancePointer: boolean\n}\n\n/**\n * The console path the owner's notification opens, in the `/{hostDocId}/rest`\n * shape every host notification uses — the console rewrites it to\n * `/{orgSlug}/hosts/{subdomain}/rest` when the link is followed, which is\n * why `hostId` and `orgId` travel on the document. The same spelling the\n * CRM's task notification uses; the hub's own route builder lives in the\n * plugin, which this layer cannot import.\n */\nfunction recordLink(hostId: string, record: AssignedRecord): string {\n const section = record.kind === 'contact' ? 'contacts' : 'leads'\n return `/${hostId}/crm/${section}/${encodeURIComponent(record.id)}`\n}\n\n/** The record an assignment notification opens. */\nexport type AssignedRecord =\n | { kind: 'contact'; id: string }\n | { kind: 'lead'; id: string }\n\n/**\n * Tells a member a record is now theirs — one console notification, typed\n * by the record it opens — unless the member handed it to themselves.\n * Shared by the pass above and by the routes where a person picks the\n * owner outright (a lead conversion naming a colleague), so every server\n * path that writes `ownerUid` says so in the same words. Never throws;\n * the fan-out swallows its own failures.\n */\nexport async function notifyRecordAssigned(input: {\n hostId: string\n orgId: string\n ownerUid: string\n actorUid?: string | null\n record: AssignedRecord\n /** How the person reads in the message: their name, else their address. */\n who: string\n}): Promise<boolean> {\n if (!input.ownerUid || input.ownerUid === (input.actorUid ?? '')) return false\n const lead = input.record.kind === 'lead'\n await notifyUsers([input.ownerUid], {\n type: lead ? 'content.leadAssigned' : 'content.contactAssigned',\n title: lead ? 'Lead assigned to you' : 'Contact assigned to you',\n body: input.who,\n link: recordLink(input.hostId, input.record),\n orgId: input.orgId,\n hostId: input.hostId,\n })\n return true\n}\n\nasync function assignOwner(policy: Policy): Promise<OwnerAssignment> {\n const { input } = policy\n try {\n const resolved = await getOrgForHost(input.hostId)\n if (!resolved) return { outcome: 'none', reason: 'no-org' }\n const { orgId } = resolved\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n const contactRef = orgRef.collection('contacts').doc(input.contactId)\n const membersRef = orgRef.collection('members')\n const leadKey = personKey(input.email)\n // Beside the contact on the org, keyed by the same address (AGL-3275).\n const leadRef = leadKey ? orgRef.collection('leads').doc(leadKey) : null\n\n let contactName = ''\n const verdict = await firestore.runTransaction(\n async (tx): Promise<OwnerAssignment> => {\n /*\n * ALL READS BEFORE THE WRITE, which Firestore requires. The org\n * document is read here rather than trusted from `getOrgForHost`,\n * because the pointer it carries is the one thing a concurrent\n * capture may have moved, and the rules and the pool are read from\n * the same snapshot so the decision and the pointer agree.\n */\n const [orgSnapshot, contactSnapshot, leadSnapshot] = await Promise.all([\n tx.get(orgRef),\n tx.get(contactRef),\n leadRef ? tx.get(leadRef) : Promise.resolve(null),\n ])\n if (!contactSnapshot.exists) {\n return { outcome: 'none', reason: 'no-contact' }\n }\n const org = (orgSnapshot.data() ?? {}) as Record<string, unknown>\n const contact = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n contactName = String(contact['name'] ?? '')\n const group = consentGroupForHost(org, input.hostId)\n const facet = readContactFacet(contact, group.groupId)\n const current = typeof facet.ownerUid === 'string' ? facet.ownerUid : ''\n if (policy.kind === 'capture' && current) {\n return { outcome: 'unchanged', ownerUid: current }\n }\n const settings = readCrmAssignmentSettings(org)\n\n // One read per candidate per attempt; a retried transaction reads\n // afresh, as it must.\n const onRoster = new Map<string, Promise<boolean>>()\n const isMember = (uid: string): Promise<boolean> => {\n let known = onRoster.get(uid)\n if (!known) {\n known = tx.get(membersRef.doc(uid)).then((snapshot) => snapshot.exists)\n onRoster.set(uid, known)\n }\n return known\n }\n const fromPool = async (): Promise<string | null> => {\n for (const uid of roundRobinOrder(\n settings.pool.memberUids,\n settings.pool.lastAssignedUid,\n )) {\n if (await isMember(uid)) return uid\n }\n return null\n }\n const resolveTarget = async (\n target: CrmAssignmentTarget,\n ): Promise<{ uid: string; advancePointer: boolean } | null> => {\n if ('roundRobin' in target) {\n const uid = await fromPool()\n return uid ? { uid, advancePointer: true } : null\n }\n return (await isMember(target.memberUid))\n ? { uid: target.memberUid, advancePointer: false }\n : null\n }\n\n let decision: Decision | null = null\n let refusal: OwnerAssignmentRefusal = 'no-rule'\n if (policy.kind === 'capture') {\n const capture = {\n source: policy.input.source,\n email: input.email,\n formId: policy.input.formId ?? null,\n tags: [...(policy.input.tags ?? []), ...(facet.tags ?? [])],\n }\n for (const rule of settings.rules) {\n if (!assignmentRuleMatches(rule.when, capture)) continue\n const hit = await resolveTarget(rule.assign)\n if (hit) {\n decision = { ...hit, ownerUid: hit.uid, by: 'rule', ruleId: rule.id }\n break\n }\n }\n const fallback = settings.hostDefaultOwners[input.hostId]\n if (!decision && fallback && (await isMember(fallback))) {\n decision = { ownerUid: fallback, by: 'default', advancePointer: false }\n }\n } else {\n const { assign } = policy.input\n const hit = await resolveTarget(assign)\n if (hit) {\n decision = {\n ...hit,\n ownerUid: hit.uid,\n by: 'roundRobin' in assign ? 'roundRobin' : 'member',\n }\n } else {\n refusal = 'roundRobin' in assign ? 'empty-pool' : 'not-a-member'\n }\n }\n if (!decision) return { outcome: 'none', reason: refusal }\n if (decision.ownerUid === current) {\n return { outcome: 'unchanged', ownerUid: current }\n }\n\n tx.update(contactRef, {\n [contactFacetPath(group.groupId, 'ownerUid')]: decision.ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n if (decision.advancePointer) {\n tx.update(orgRef, {\n [CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH]: decision.ownerUid,\n })\n }\n /*\n * A lead somebody already assigned by hand keeps its owner on a\n * capture — the contact was new, the lead may not have been — and\n * follows the contact on a deliberate reassignment.\n */\n const leadOwner = String(leadSnapshot?.get('ownerUid') ?? '')\n const leadMirrored = Boolean(\n leadSnapshot?.exists && (policy.kind === 'reassign' || !leadOwner),\n )\n if (leadMirrored && leadRef) {\n tx.update(leadRef, {\n ownerUid: decision.ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n }\n return {\n outcome: 'assigned',\n ownerUid: decision.ownerUid,\n by: decision.by,\n ...(decision.ruleId ? { ruleId: decision.ruleId } : {}),\n leadMirrored,\n notified: false,\n }\n },\n )\n if (verdict.outcome !== 'assigned') return verdict\n // The owner is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n\n if (\n !verdict.leadMirrored &&\n leadRef &&\n policy.kind === 'capture' &&\n LEAD_FILING_SOURCES.has(policy.input.source)\n ) {\n void mirrorOwnerOntoLeadLater(leadRef, verdict.ownerUid)\n }\n\n const notified = await notifyRecordAssigned({\n hostId: input.hostId,\n orgId,\n ownerUid: verdict.ownerUid,\n actorUid: input.actorUid,\n record:\n verdict.leadMirrored && leadKey\n ? { kind: 'lead', id: leadKey }\n : { kind: 'contact', id: input.contactId },\n who: contactName || input.email,\n })\n return { ...verdict, notified }\n } catch (error) {\n console.error(\n 'assignContactOwner failed',\n input.hostId,\n input.contactId,\n error,\n )\n return { outcome: 'none', reason: 'failed' }\n }\n}\n\n/**\n * The detached second look for a lead that lands after the contact.\n *\n * Bounded, and only ever ADDS an owner: a lead that arrived with one (a\n * person assigned it in the meantime) is left alone, and a lead that never\n * arrives costs three reads over six seconds and nothing else. Detached\n * because the capture must not hold `contactCreated` back for it — an\n * automation welcoming the contact has nothing to do with the lead's owner\n * column.\n */\nasync function mirrorOwnerOntoLeadLater(\n leadRef: FirebaseFirestore.DocumentReference,\n ownerUid: string,\n delaysMs: readonly number[] = LEAD_MIRROR_RETRY_MS,\n): Promise<boolean> {\n for (const delay of delaysMs) {\n await new Promise((resolve) => setTimeout(resolve, delay))\n try {\n const lead = await leadRef.get()\n if (!lead.exists) continue\n if (String(lead.get('ownerUid') ?? '')) return false\n await leadRef.update({\n ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n return true\n } catch (error) {\n console.error('assignContactOwner lead mirror failed', leadRef.path, error)\n return false\n }\n }\n return false\n}\n\n/**\n * The capture door's pass: the org's rules in order, then the capturing\n * site's default owner, on a record that has no owner yet. See the module\n * comment for the whole of it.\n */\nexport function assignOwnerForCapture(\n input: CaptureAssignmentInput,\n): Promise<OwnerAssignment> {\n return assignOwner({ kind: 'capture', input })\n}\n\n/**\n * The deliberate assignment — the `assignContactOwner` step, and a person\n * handing a record to somebody: a member by uid (verified on the roster) or\n * the next in the round-robin pool, overwriting whoever held it. Naming the\n * owner the record already has changes nothing and tells nobody.\n */\nexport function reassignContactOwner(\n input: ReassignmentInput,\n): Promise<OwnerAssignment> {\n return assignOwner({ kind: 'reassign', input })\n}\n\nexport default assignOwnerForCapture\n"],"names":["assignmentRuleMatches","consentGroupForHost","contactFacetPath","CRM_ROUND_ROBIN_LAST_ASSIGNED_PATH","personKey","readContactFacet","readCrmAssignmentSettings","roundRobinOrder","firebaseAdmin","getOrgForHost","notifyUsers","restampCrmListFieldsAt","FieldValue","OWNER_ASSIGNMENT_REFUSALS","failed","LEAD_FILING_SOURCES","Set","LEAD_MIRROR_RETRY_MS","recordLink","hostId","record","section","kind","encodeURIComponent","id","notifyRecordAssigned","input","ownerUid","actorUid","lead","type","title","body","who","link","orgId","assignOwner","policy","resolved","outcome","reason","firestore","app","orgRef","collection","doc","contactRef","contactId","membersRef","leadKey","email","leadRef","contactName","verdict","runTransaction","tx","orgSnapshot","contactSnapshot","contact","leadSnapshot","Promise","all","get","resolve","exists","org","data","String","group","facet","groupId","current","settings","onRoster","Map","isMember","uid","known","then","snapshot","set","fromPool","pool","memberUids","lastAssignedUid","resolveTarget","target","advancePointer","memberUid","decision","refusal","capture","source","formId","tags","rule","rules","when","hit","assign","by","ruleId","fallback","hostDefaultOwners","update","updatedAt","serverTimestamp","leadOwner","leadMirrored","Boolean","notified","has","mirrorOwnerOntoLeadLater","error","console","delaysMs","delay","setTimeout","path","assignOwnerForCapture","reassignContactOwner"],"mappings":"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"}
|
|
@@ -15,8 +15,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
17
|
*/ import { companyDomainForEmail, companyNameForDomain, CRM_COLLECTIONS, crmReadTokens, crmScopeTokens, nameSearchFields, orgAutoCreatesCompanies } from "@aglyn/aglyn/server";
|
|
18
|
-
import { consentGroupForSite, contactCompanyLinkFields, firebaseAdmin, getOrgForHost, writeContactCompanyLink } from "@aglyn/tenant-data-admin";
|
|
19
|
-
import { COMPANY_CONTACTS_COUNT_FIELD, planContactCompanyLink } from "@aglyn/aglyn/server";
|
|
18
|
+
import { consentGroupForSite, contactCompanyLinkFields, firebaseAdmin, getOrgForHost, restampCrmListFieldsAt, writeContactCompanyLink } from "@aglyn/tenant-data-admin";
|
|
19
|
+
import { COMPANY_CONTACTS_COUNT_FIELD, crmNewRecordListFields, planContactCompanyLink } from "@aglyn/aglyn/server";
|
|
20
20
|
import { FieldValue } from "firebase-admin/firestore";
|
|
21
21
|
/**
|
|
22
22
|
* Link a NEW contact to the company its email domain names (AGL-2613).
|
|
@@ -121,18 +121,21 @@ import { FieldValue } from "firebase-admin/firestore";
|
|
|
121
121
|
};
|
|
122
122
|
const companyRef = companiesRef.doc(plan.companyId);
|
|
123
123
|
const batch = firestore.batch();
|
|
124
|
-
|
|
124
|
+
const company = _extends({}, nameSearchFields(companyNameForDomain(domain)), {
|
|
125
125
|
domain,
|
|
126
126
|
visibleTo: crmScopeTokens(org, group),
|
|
127
127
|
hostId: created.hostId,
|
|
128
128
|
[COMPANY_CONTACTS_COUNT_FIELD]: 1,
|
|
129
129
|
createdAt: FieldValue.serverTimestamp(),
|
|
130
130
|
updatedAt: FieldValue.serverTimestamp()
|
|
131
|
-
})
|
|
131
|
+
});
|
|
132
|
+
batch.set(companyRef, _extends({}, company, crmNewRecordListFields('companies', company)));
|
|
132
133
|
batch.update(contactRef, _extends({}, contactCompanyLinkFields(plan, group.groupId), {
|
|
133
134
|
updatedAt: FieldValue.serverTimestamp()
|
|
134
135
|
}));
|
|
135
136
|
await batch.commit();
|
|
137
|
+
// The company is what the Contacts list filters by (AGL-3321).
|
|
138
|
+
await restampCrmListFieldsAt(contactRef, 'contacts');
|
|
136
139
|
return {
|
|
137
140
|
outcome: 'created',
|
|
138
141
|
companyId: plan.companyId
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/associate-company-by-domain.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n companyDomainForEmail,\n companyNameForDomain,\n CRM_COLLECTIONS,\n crmReadTokens,\n crmScopeTokens,\n nameSearchFields,\n orgAutoCreatesCompanies,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n contactCompanyLinkFields,\n firebaseAdmin,\n getOrgForHost,\n type HostContactCreated,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { COMPANY_CONTACTS_COUNT_FIELD, planContactCompanyLink } from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** What the association did, for the spec and the log. */\nexport type CompanyAssociation =\n | { outcome: 'linked' | 'created'; companyId: string }\n | {\n outcome: 'none'\n reason: 'no-domain' | 'no-org' | 'ambiguous' | 'no-match' | 'failed'\n }\n\n/**\n * Link a NEW contact to the company its email domain names (AGL-2613).\n *\n * HubSpot associates a contact with a company on domain the moment the\n * contact exists, and that is the moment this runs: once, from the capture\n * door, for a person the org did not hold before. A repeat visit by somebody\n * already held is not a new person and re-asks nothing; a door that already\n * knew the company — the console's create drawer, an import row naming one\n * — has said so in the facet and this is not called at all.\n *\n * ## Bounded: one query, one commit\n *\n * The query is `companies where domain == d and visibleTo array-contains-any\n * (the capturing scope's read tokens)`, capped at two — the `(visibleTo\n * CONTAINS, domain ASC)` composite serves it. Two rather than one because\n * the answer has to be \"exactly one\": two companies at one domain visible\n * to this site is an ambiguity a capture must not resolve by picking the\n * first, and the contact waits for a person. The scope predicate is the\n * console's own listener predicate, so a company this site could not open\n * is not a match — an agency's client at `acme.com` is not linked to a\n * sibling client's Acme.\n *\n * The write is one batch: the facet and the mirror on the contact, and the\n * count on the company. The contact is fresh, so the plan is the trivial\n * one — no previous link, nothing held elsewhere — and needs no read of the\n * document.\n *\n * ## Creating is a setting, and off by default\n *\n * With `crm.autoCreateCompanies` on the org document, a domain no visible\n * company carries becomes one, named after the domain and scoped exactly as\n * the contact was — `crmScopeTokens`, the stamp every CRM creator uses —\n * with the count starting at one. Public mailbox domains never reach this\n * branch, because `companyDomainForEmail` answers `null` for them: a\n * workspace's consumer list is not a list of accounts.\n *\n * ## The same posture as the capture\n *\n * Never throws. The capture has already succeeded and a form submission\n * must not fail because the CRM could not file its author; a failure is\n * logged and answered as `none`, and the person is exactly as capturable\n * by hand as they were before this existed.\n */\nexport async function associateCompanyByDomain(\n created: Pick<HostContactCreated, 'hostId' | 'contactId' | 'email'>,\n): Promise<CompanyAssociation> {\n const domain = companyDomainForEmail(created.email)\n if (!domain) return { outcome: 'none', reason: 'no-domain' }\n try {\n const resolved = await getOrgForHost(created.hostId)\n if (!resolved) return { outcome: 'none', reason: 'no-org' }\n const org = resolved.org as Record<string, unknown>\n const group = await consentGroupForSite(created.hostId, org)\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(resolved.orgId)\n const companiesRef = orgRef.collection(CRM_COLLECTIONS.companies)\n const contactRef = orgRef.collection('contacts').doc(created.contactId)\n\n const matches = await companiesRef\n .where('domain', '==', domain)\n .where('visibleTo', 'array-contains-any', crmReadTokens(group))\n .limit(2)\n .get()\n if (matches.size > 1) return { outcome: 'none', reason: 'ambiguous' }\n if (matches.size === 1) {\n const companyId = matches.docs[0].id\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: null,\n companiesRef,\n groupId: group.groupId,\n companyId,\n })\n return { outcome: 'linked', companyId }\n }\n if (!orgAutoCreatesCompanies(org)) {\n return { outcome: 'none', reason: 'no-match' }\n }\n\n /*\n * The company and the link in one commit, so a company never exists\n * with a count of one and no contact naming it — or the reverse. The\n * count is written as a value rather than an increment because the\n * document is being created in the same batch, and the plan's `+1` is\n * exactly what a fresh company with one contact stores.\n */\n const plan = planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n companiesRef.doc().id,\n )\n if (!plan?.companyId) return { outcome: 'none', reason: 'failed' }\n const companyRef = companiesRef.doc(plan.companyId)\n const batch = firestore.batch()\n batch.set(companyRef, {\n ...nameSearchFields(companyNameForDomain(domain)),\n domain,\n visibleTo: crmScopeTokens(org, group),\n hostId: created.hostId,\n [COMPANY_CONTACTS_COUNT_FIELD]: 1,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, group.groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await batch.commit()\n return { outcome: 'created', companyId: plan.companyId }\n } catch (error) {\n console.error(\n 'associateCompanyByDomain failed',\n created.hostId,\n created.contactId,\n error,\n )\n return { outcome: 'none', reason: 'failed' }\n }\n}\n\nexport default associateCompanyByDomain\n"],"names":["companyDomainForEmail","companyNameForDomain","CRM_COLLECTIONS","crmReadTokens","crmScopeTokens","nameSearchFields","orgAutoCreatesCompanies","consentGroupForSite","contactCompanyLinkFields","firebaseAdmin","getOrgForHost","writeContactCompanyLink","COMPANY_CONTACTS_COUNT_FIELD","planContactCompanyLink","FieldValue","associateCompanyByDomain","created","domain","email","outcome","reason","resolved","hostId","org","group","firestore","app","orgRef","collection","doc","orgId","companiesRef","companies","contactRef","contactId","matches","where","limit","get","size","companyId","docs","id","contact","groupId","plan","companyIds","heldElsewhere","companyRef","batch","set","visibleTo","createdAt","serverTimestamp","updatedAt","update","commit","error","console"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/associate-company-by-domain.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n companyDomainForEmail,\n companyNameForDomain,\n CRM_COLLECTIONS,\n crmReadTokens,\n crmScopeTokens,\n nameSearchFields,\n orgAutoCreatesCompanies,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n contactCompanyLinkFields,\n firebaseAdmin,\n getOrgForHost,\n type HostContactCreated,\n restampCrmListFieldsAt,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport {\n COMPANY_CONTACTS_COUNT_FIELD,\n crmNewRecordListFields,\n planContactCompanyLink,\n} from '@aglyn/aglyn/server'\nimport { FieldValue } from 'firebase-admin/firestore'\n\n/** What the association did, for the spec and the log. */\nexport type CompanyAssociation =\n | { outcome: 'linked' | 'created'; companyId: string }\n | {\n outcome: 'none'\n reason: 'no-domain' | 'no-org' | 'ambiguous' | 'no-match' | 'failed'\n }\n\n/**\n * Link a NEW contact to the company its email domain names (AGL-2613).\n *\n * HubSpot associates a contact with a company on domain the moment the\n * contact exists, and that is the moment this runs: once, from the capture\n * door, for a person the org did not hold before. A repeat visit by somebody\n * already held is not a new person and re-asks nothing; a door that already\n * knew the company — the console's create drawer, an import row naming one\n * — has said so in the facet and this is not called at all.\n *\n * ## Bounded: one query, one commit\n *\n * The query is `companies where domain == d and visibleTo array-contains-any\n * (the capturing scope's read tokens)`, capped at two — the `(visibleTo\n * CONTAINS, domain ASC)` composite serves it. Two rather than one because\n * the answer has to be \"exactly one\": two companies at one domain visible\n * to this site is an ambiguity a capture must not resolve by picking the\n * first, and the contact waits for a person. The scope predicate is the\n * console's own listener predicate, so a company this site could not open\n * is not a match — an agency's client at `acme.com` is not linked to a\n * sibling client's Acme.\n *\n * The write is one batch: the facet and the mirror on the contact, and the\n * count on the company. The contact is fresh, so the plan is the trivial\n * one — no previous link, nothing held elsewhere — and needs no read of the\n * document.\n *\n * ## Creating is a setting, and off by default\n *\n * With `crm.autoCreateCompanies` on the org document, a domain no visible\n * company carries becomes one, named after the domain and scoped exactly as\n * the contact was — `crmScopeTokens`, the stamp every CRM creator uses —\n * with the count starting at one. Public mailbox domains never reach this\n * branch, because `companyDomainForEmail` answers `null` for them: a\n * workspace's consumer list is not a list of accounts.\n *\n * ## The same posture as the capture\n *\n * Never throws. The capture has already succeeded and a form submission\n * must not fail because the CRM could not file its author; a failure is\n * logged and answered as `none`, and the person is exactly as capturable\n * by hand as they were before this existed.\n */\nexport async function associateCompanyByDomain(\n created: Pick<HostContactCreated, 'hostId' | 'contactId' | 'email'>,\n): Promise<CompanyAssociation> {\n const domain = companyDomainForEmail(created.email)\n if (!domain) return { outcome: 'none', reason: 'no-domain' }\n try {\n const resolved = await getOrgForHost(created.hostId)\n if (!resolved) return { outcome: 'none', reason: 'no-org' }\n const org = resolved.org as Record<string, unknown>\n const group = await consentGroupForSite(created.hostId, org)\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(resolved.orgId)\n const companiesRef = orgRef.collection(CRM_COLLECTIONS.companies)\n const contactRef = orgRef.collection('contacts').doc(created.contactId)\n\n const matches = await companiesRef\n .where('domain', '==', domain)\n .where('visibleTo', 'array-contains-any', crmReadTokens(group))\n .limit(2)\n .get()\n if (matches.size > 1) return { outcome: 'none', reason: 'ambiguous' }\n if (matches.size === 1) {\n const companyId = matches.docs[0].id\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: null,\n companiesRef,\n groupId: group.groupId,\n companyId,\n })\n return { outcome: 'linked', companyId }\n }\n if (!orgAutoCreatesCompanies(org)) {\n return { outcome: 'none', reason: 'no-match' }\n }\n\n /*\n * The company and the link in one commit, so a company never exists\n * with a count of one and no contact naming it — or the reverse. The\n * count is written as a value rather than an increment because the\n * document is being created in the same batch, and the plan's `+1` is\n * exactly what a fresh company with one contact stores.\n */\n const plan = planContactCompanyLink(\n { companyId: null, companyIds: [], heldElsewhere: [] },\n companiesRef.doc().id,\n )\n if (!plan?.companyId) return { outcome: 'none', reason: 'failed' }\n const companyRef = companiesRef.doc(plan.companyId)\n const batch = firestore.batch()\n const company: Record<string, unknown> = {\n ...nameSearchFields(companyNameForDomain(domain)),\n domain,\n visibleTo: crmScopeTokens(org, group),\n hostId: created.hostId,\n [COMPANY_CONTACTS_COUNT_FIELD]: 1,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n batch.set(companyRef, {\n ...company,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', company),\n })\n batch.update(contactRef, {\n ...contactCompanyLinkFields(plan, group.groupId),\n updatedAt: FieldValue.serverTimestamp(),\n })\n await batch.commit()\n // The company is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n return { outcome: 'created', companyId: plan.companyId }\n } catch (error) {\n console.error(\n 'associateCompanyByDomain failed',\n created.hostId,\n created.contactId,\n error,\n )\n return { outcome: 'none', reason: 'failed' }\n }\n}\n\nexport default associateCompanyByDomain\n"],"names":["companyDomainForEmail","companyNameForDomain","CRM_COLLECTIONS","crmReadTokens","crmScopeTokens","nameSearchFields","orgAutoCreatesCompanies","consentGroupForSite","contactCompanyLinkFields","firebaseAdmin","getOrgForHost","restampCrmListFieldsAt","writeContactCompanyLink","COMPANY_CONTACTS_COUNT_FIELD","crmNewRecordListFields","planContactCompanyLink","FieldValue","associateCompanyByDomain","created","domain","email","outcome","reason","resolved","hostId","org","group","firestore","app","orgRef","collection","doc","orgId","companiesRef","companies","contactRef","contactId","matches","where","limit","get","size","companyId","docs","id","contact","groupId","plan","companyIds","heldElsewhere","companyRef","batch","company","visibleTo","createdAt","serverTimestamp","updatedAt","set","update","commit","error","console"],"mappings":"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"}
|
|
@@ -73,8 +73,8 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
73
73
|
* nobody chose an owner for — and no rule assigned — stays unowned, which
|
|
74
74
|
* the console renders honestly as unassigned. Either way the caller has
|
|
75
75
|
* already been authorized by its door; nothing here asks again.
|
|
76
|
-
*/ import { consentGroupDisclosureKey, consentGroupScope, CRM_COLLECTIONS, crmScopeTokens, DEFAULT_DEAL_STAGES, grantedUnderConsentGroup, nameSearchFields, contactFacetPath, normalizeContactEmail, ORG_SCOPE_TOKEN, readContactFacet, readMarketingBasis, soloConsentGroup } from "@aglyn/aglyn/server";
|
|
77
|
-
import { consentGroupForSite, crmRecordsQuotaForOrg, findContactByEmail, logHostActivity, orgDataCollectionForHost, writeContactCompanyLink } from "@aglyn/tenant-data-admin";
|
|
76
|
+
*/ import { consentGroupDisclosureKey, consentGroupScope, CRM_COLLECTIONS, crmScopeTokens, DEFAULT_DEAL_STAGES, grantedUnderConsentGroup, nameSearchFields, contactFacetPath, normalizeContactEmail, ORG_SCOPE_TOKEN, readContactFacet, crmNewRecordListFields, readMarketingBasis, soloConsentGroup } from "@aglyn/aglyn/server";
|
|
77
|
+
import { consentGroupForSite, crmRecordsQuotaForOrg, findContactByEmail, logHostActivity, orgDataCollectionForHost, restampCrmListFieldsAt, writeContactCompanyLink } from "@aglyn/tenant-data-admin";
|
|
78
78
|
import { FieldPath, FieldValue } from "firebase-admin/firestore";
|
|
79
79
|
import { assignOwnerForCapture, notifyRecordAssigned } from "./assign-contact-owner.js";
|
|
80
80
|
import { captureHostContact } from "./capture-host-contact.js";
|
|
@@ -285,6 +285,8 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
285
285
|
[contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,
|
|
286
286
|
updatedAt: FieldValue.serverTimestamp()
|
|
287
287
|
});
|
|
288
|
+
// The owner is what the Contacts list filters by (AGL-3321).
|
|
289
|
+
await restampCrmListFieldsAt(contactRef, 'contacts');
|
|
288
290
|
} else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {
|
|
289
291
|
await notifyRecordAssigned({
|
|
290
292
|
hostId,
|
|
@@ -335,7 +337,7 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
335
337
|
ok: false,
|
|
336
338
|
reason: 'band-full'
|
|
337
339
|
};
|
|
338
|
-
const
|
|
340
|
+
const company = _extends({}, nameSearchFields(createCompany.name), createCompany.domain ? {
|
|
339
341
|
domain: createCompany.domain
|
|
340
342
|
} : {}, ownerUid ? {
|
|
341
343
|
ownerUid
|
|
@@ -345,7 +347,8 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
345
347
|
createdByUid: actor.uid,
|
|
346
348
|
createdAt: FieldValue.serverTimestamp(),
|
|
347
349
|
updatedAt: FieldValue.serverTimestamp()
|
|
348
|
-
})
|
|
350
|
+
});
|
|
351
|
+
const created = await orgRef.collection(CRM_COLLECTIONS.companies).add(_extends({}, company, crmNewRecordListFields('companies', company)));
|
|
349
352
|
companyId = created.id;
|
|
350
353
|
}
|
|
351
354
|
}
|
|
@@ -417,7 +420,7 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
417
420
|
ok: false,
|
|
418
421
|
reason: 'band-full'
|
|
419
422
|
};
|
|
420
|
-
const
|
|
423
|
+
const dealRecord = _extends({
|
|
421
424
|
title: deal.title,
|
|
422
425
|
titleLower: deal.title.toLowerCase(),
|
|
423
426
|
pipelineId,
|
|
@@ -440,7 +443,8 @@ import { handOffLeadRecords } from "./hand-off-lead.js";
|
|
|
440
443
|
createdByUid: actor.uid,
|
|
441
444
|
createdAt: FieldValue.serverTimestamp(),
|
|
442
445
|
updatedAt: FieldValue.serverTimestamp()
|
|
443
|
-
})
|
|
446
|
+
});
|
|
447
|
+
const created = await orgRef.collection(CRM_COLLECTIONS.deals).add(_extends({}, dealRecord, crmNewRecordListFields('deals', dealRecord)));
|
|
444
448
|
dealId = created.id;
|
|
445
449
|
}
|
|
446
450
|
/*==========================================
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../libs/tenant/runtime/src/lib/convert-host-lead.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * A lead becomes a contact, and optionally a company and a deal (AGL-2608,\n * AGL-2627).\n *\n * ## Why one function under two doors\n *\n * The console's convert dialog posts to the CRM plugin's `crm/lead-convert`\n * route, and an integration posts to `POST /v1/leads/{id}/convert`. The two\n * doors authenticate differently — an ID token against the site's role, an\n * API key against its scopes — and answer in different envelopes, but what\n * they DO has to be one thing: a conversion that opened a second deal for\n * one person because two callers reached the same lead through two\n * implementations is the bug this file exists to make impossible. The doors\n * own their transport; this owns the writes.\n *\n * It lives here, in the tenancy runtime, rather than beside the plugin\n * route it came from, because the console's REST layer may not import a\n * feature plugin (the app→addons boundary), while both the plugin and the\n * console may import the runtime — which already holds the capture door\n * and the owner assignment this conversion goes through.\n *\n * ## Why a server function and not four client writes\n *\n * The lead is a host document the browser may edit, and a company or a deal\n * is a client-creatable org document. What the browser cannot do is CREATE A\n * CONTACT: the contacts collection is written only by `captureHostContact`\n * (the runtime's wrapper over `upsertHostContact`, so `contactCreated`\n * fires), because that function is the dedupe — one human, one row, found by\n * normalized address across every site in the org — and the audience-band\n * gate. A client that wrote a contact document of its own would mint a\n * second row for a person the org already holds, and would do it past the\n * band.\n *\n * Once the contact has to come from the server, the rest follows it: the\n * conversion is one act with four writes, and a caller that made three of\n * them and lost the connection before the fourth leaves a lead marked\n * qualified with no contact, or a deal pointing at a contact that was never\n * stamped on the lead. Here the order is fixed — contact, then company, then\n * deal, then the lead — and the lead is stamped LAST, so a lead that carries\n * `convertedContactId` names a contact that exists.\n *\n * ## Idempotent on the lead\n *\n * A lead already carrying `convertedContactId` answers with the ids it has\n * and creates nothing more. A double-click on the dialog's button, a retry\n * after a slow response, or a Zapier zap replaying a step must not open a\n * second deal for one person.\n *\n * ## The actor\n *\n * A signed-in member converting from the console owns what nobody else\n * claimed: their uid is the owner of last resort, written directly rather\n * than through the deliberate reassignment, because they may be staff\n * converting on a workspace's behalf and a roster check would refuse the one\n * person who is actually here. An API key is not a person and is on no\n * roster, so for an `api` actor that last step is skipped and a contact\n * nobody chose an owner for — and no rule assigned — stays unowned, which\n * the console renders honestly as unassigned. Either way the caller has\n * already been authorized by its door; nothing here asks again.\n */\n\nimport {\n consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n grantedUnderConsentGroup,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n 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 /*\n * WHICH SITES THE CONTACT'S GRANT COVERS (AGL-3320): the lead's, never\n * more. The capture door records a fresh grant, so it is handed the group's\n * disclosure key only when the lead ALREADY holds a grant given under the\n * group as it stands, at every site it names — a lead captured by one site\n * before the group existed, or before a site joined, converts to a contact\n * that site alone may mail, exactly as the lead was.\n */\n const leadDisclosure = grantedUnderConsentGroup(lead, group)\n ? consentGroupDisclosureKey(group)\n : null\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadConsent.basis === 'granted' && leadDisclosure\n ? { disclosedConsentGroup: leadDisclosure }\n : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n // Salesforce's Lead Source travels to the contact (AGL-3298).\n ...(typeof lead.leadSource === 'string' && lead.leadSource\n ? { leadSource: lead.leadSource }\n : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n } 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":["consentGroupDisclosureKey","consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","grantedUnderConsentGroup","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","leadDisclosure","captured","name","source","interaction","summary","refId","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","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 consentGroupDisclosureKey,\n consentGroupScope,\n CRM_COLLECTIONS,\n type CrmDealStage,\n type CrmDealStatus,\n type CrmLeadFields,\n type CrmPipeline,\n crmScopeTokens,\n DEFAULT_DEAL_STAGES,\n grantedUnderConsentGroup,\n nameSearchFields,\n contactFacetPath,\n normalizeContactEmail,\n ORG_SCOPE_TOKEN,\n readContactFacet,\n crmNewRecordListFields,\n readMarketingBasis,\n soloConsentGroup,\n} from '@aglyn/aglyn/server'\nimport {\n consentGroupForSite,\n crmRecordsQuotaForOrg,\n findContactByEmail,\n logHostActivity,\n orgDataCollectionForHost,\n restampCrmListFieldsAt,\n writeContactCompanyLink,\n} from '@aglyn/tenant-data-admin'\nimport { FieldPath, FieldValue } from 'firebase-admin/firestore'\nimport { assignOwnerForCapture, notifyRecordAssigned } from './assign-contact-owner'\nimport { captureHostContact } from './capture-host-contact'\nimport { handOffLeadRecords } from './hand-off-lead'\n\n/** Who is converting, as far as the writes need to know. */\nexport interface LeadConvertActor {\n /**\n * What `createdByUid` records on every record the conversion opens, and\n * who the audit line names. A member's uid, or the literal `'api'` for a\n * key — the same attribution the REST creates stamp.\n */\n uid: string\n email?: string | null\n /**\n * `member`: a signed-in person, who becomes the owner when nobody else\n * does. `api`: a key, which cannot own a record — see the header.\n */\n kind: 'member' | 'api'\n /**\n * For an `api` actor, the key's name — what the audit line attributes the\n * conversion to, since a key has no address to be named by.\n */\n apiKeyName?: string | null\n}\n\n/** What a conversion is asked to do. Validated by the door before it gets here. */\nexport interface ConvertHostLeadInput {\n firestore: FirebaseFirestore.Firestore\n hostId: string\n orgId: string\n /** The org document, as the door resolved it. */\n org: Record<string, unknown>\n /** `hosts/{hostId}/leads/{leadId}` — the document id, which is the person key. */\n leadId: string\n actor: LeadConvertActor\n /**\n * Who owns the resulting contact (and deal). Defaults to the lead's own\n * owner; failing that the org's assignment rules and the site's default\n * owner decide (AGL-2618), and failing those a member actor — somebody\n * converted this person, and a record with no owner is one nobody follows\n * up.\n */\n ownerUid?: string\n /** Link an existing `orgs/{orgId}/companies/{companyId}`. */\n companyId?: string\n /** Or create one. Ignored when `companyId` is given. Already normalized. */\n createCompany?: { name: string; domain: string | null } | null\n /** Open a deal in the org's default pipeline. Already normalized. */\n deal?: {\n title: string\n amountCents: number | null\n /** Lowercase ISO 4217. */\n currency: string\n /** A stage of the default pipeline; its first open stage when absent. */\n stageId?: string\n } | null\n}\n\n/**\n * Why a conversion did not happen. Each is a state of the data, not a fault\n * of the request — the door has already refused a malformed body — and each\n * leaves the lead unconverted so a retry after the remedy finds it where it\n * was.\n */\nexport type ConvertHostLeadRefusal =\n /** No document at `hosts/{hostId}/leads/{leadId}`. */\n | 'unknown-lead'\n /** The lead's address cannot become a contact. */\n | 'no-email'\n /**\n * The capture door produced no contact — it swallows its own failures and\n * drops a creation past a Free org's audience band. Nothing was written.\n */\n | 'contact-not-created'\n /**\n * The site holds an erasure for the lead's address (AGL-2623): the person\n * asked to be forgotten, and the capture door refuses to rebuild a record\n * for them — by a conversion as by a form. Nothing was written, and the\n * lead stays as it was; the erasure removes it when it runs.\n */\n | 'erased'\n /** The CRM records band is full (AGL-2611); the contact stands, nothing more was opened. */\n | 'band-full'\n /** `companyId` names no company in this organization. The contact stands. */\n | 'unknown-company'\n /** The default pipeline has no stages to open a deal in. The contact stands. */\n | 'no-stages'\n\nexport type ConvertHostLeadResult =\n | {\n ok: true\n contactId: string\n companyId?: string\n dealId?: string\n /** The lead was converted before this call; nothing was created now. */\n alreadyConverted: boolean\n }\n | { ok: false; reason: ConvertHostLeadRefusal }\n\n/** The stamp the conversion leaves on the lead — see `CrmLeadFields`. */\ntype LeadConversionStamp = Required<\n Pick<CrmLeadFields, 'status' | 'convertedContactId' | 'convertedAtMs'>\n> &\n Pick<CrmLeadFields, 'dealId' | 'companyId' | 'ownerUid'>\n\n/**\n * The stage a new deal opens in.\n *\n * The caller's choice when the pipeline has it, otherwise the first OPEN\n * stage by order — a deal created by converting a lead is by definition not\n * yet won or lost, so defaulting to a closed stage would record an outcome\n * nobody reached. A pipeline with no open stage at all falls back to its\n * first stage rather than refusing: the merchant edited their pipeline into\n * that shape, and a conversion that failed because of it would read as a bug\n * in the lead.\n */\nexport function stageForNewDeal(\n pipeline: Pick<CrmPipeline, 'stages'>,\n requestedStageId: string | undefined,\n): CrmDealStage | null {\n const stages = [...(pipeline.stages ?? [])].sort((a, b) => a.order - b.order)\n if (!stages.length) return null\n const requested = requestedStageId\n ? stages.find((stage) => stage.id === requestedStageId)\n : undefined\n return requested ?? stages.find((stage) => stage.kind === 'open') ?? stages[0]\n}\n\n/** A stage's kind as the deal's denormalized status. */\nfunction dealStatusForStage(stage: CrmDealStage): CrmDealStatus {\n return stage.kind === 'won' ? 'won' : stage.kind === 'lost' ? 'lost' : 'open'\n}\n\n/**\n * Convert one lead. Throws only on infrastructure failure; every refusal\n * the data can produce is a `ConvertHostLeadResult`.\n */\nexport async function convertHostLead(\n input: ConvertHostLeadInput,\n): Promise<ConvertHostLeadResult> {\n const { firestore, hostId, orgId, org, leadId, actor, createCompany, deal } = input\n const requestedCompanyId = String(input.companyId ?? '').trim()\n // The lead's one home, on the org (AGL-3275). `orgId` is already resolved\n // by the caller, so this needs no second lookup to find it.\n const leadRef = firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .doc(leadId)\n const leadSnapshot = await leadRef.get()\n if (!leadSnapshot.exists) return { ok: false, reason: 'unknown-lead' }\n const lead = (leadSnapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n if (lead.convertedContactId) {\n return {\n ok: true,\n contactId: lead.convertedContactId,\n ...(lead.companyId ? { companyId: lead.companyId } : {}),\n ...(lead.dealId ? { dealId: lead.dealId } : {}),\n alreadyConverted: true,\n }\n }\n const email = normalizeContactEmail(lead['email'])\n if (!email) return { ok: false, reason: 'no-email' }\n\n const group = await consentGroupForSite(hostId, org)\n const visibleTo = crmScopeTokens(org, group)\n /*\n * What the CALLER may read, for the company-by-domain reuse below: their\n * group's own tokens plus `org`, exactly the set the console's listeners\n * filter on. A company outside it is one they could not open, so linking\n * a contact to it would point at a record the page then 404s on.\n */\n const readableTokens = new Set<string>([\n ORG_SCOPE_TOKEN,\n ...consentGroupScope(group),\n ])\n /*\n * THE OWNER A PERSON CHOSE, when one did: the converter's pick, else\n * whoever was already working the lead. Handed to the capture as the\n * facet's owner, which is what tells the capture's own assignment pass\n * to stand down — a person's choice outranks a rule. When nobody chose,\n * the facet names no owner and the pass runs the org's rules and the\n * site's default for a contact it creates (AGL-2618).\n */\n const pickedOwner = String(input.ownerUid ?? '').trim()\n const chosenOwner =\n pickedOwner || (typeof lead.ownerUid === 'string' ? lead.ownerUid : '')\n const now = Date.now()\n const leadName = typeof lead['name'] === 'string' ? lead['name'] : undefined\n\n /*==========================================\n * 1. THE CONTACT — through the one door that dedupes and meters.\n *\n * `source: 'manual'` because a person converted this lead by hand, and\n * the source filter should say so; the interaction names the lead so the\n * contact's timeline can be walked back to what was captured. The facet\n * carries the stage and the owner, which is what makes this a sales\n * record rather than another form capture.\n *\n * THE LEAD'S OWN PROFILE TRAVELS WITH IT (AGL-3233), the way Salesforce\n * hands a lead's fields to the contact: phone, title, lead source, address, the\n * company as text (until step 2 links a record, whose own name then\n * replaces it), tags, and the marketing basis the lead recorded — the\n * person ticked the box on the lead, and a conversion must not be the\n * moment they lose it. Only what the lead holds is handed over; a value\n * the contact already carries is the contact's, because the capture door\n * writes a profile field only where the facet has none.\n *=========================================*/\n const leadTags = Array.isArray(lead.tags) ? lead.tags.map(String) : []\n const leadConsent = readMarketingBasis(lead, soloConsentGroup(hostId))\n /*\n * WHICH SITES THE CONTACT'S GRANT COVERS (AGL-3320): the lead's, never\n * more. The capture door records a fresh grant, so it is handed the group's\n * disclosure key only when the lead ALREADY holds a grant given under the\n * group as it stands, at every site it names — a lead captured by one site\n * before the group existed, or before a site joined, converts to a contact\n * that site alone may mail, exactly as the lead was.\n */\n const leadDisclosure = grantedUnderConsentGroup(lead, group)\n ? consentGroupDisclosureKey(group)\n : null\n const captured = await captureHostContact({\n hostId,\n email,\n ...(leadName ? { name: leadName } : {}),\n source: 'manual',\n interaction: { summary: 'Converted from a lead', refId: leadId },\n ...(leadConsent.basis === 'granted' ? { marketingConsent: true } : {}),\n ...(leadConsent.basis === 'granted' && leadDisclosure\n ? { disclosedConsentGroup: leadDisclosure }\n : {}),\n ...(leadTags.length ? { tags: leadTags } : {}),\n facet: {\n lifecycleStage: 'sales-qualified',\n ...(chosenOwner ? { ownerUid: chosenOwner } : {}),\n ...(typeof lead.phone === 'string' && lead.phone ? { phone: lead.phone } : {}),\n ...(typeof lead.jobTitle === 'string' && lead.jobTitle ? { jobTitle: lead.jobTitle } : {}),\n // Salesforce's Lead Source travels to the contact (AGL-3298).\n ...(typeof lead.leadSource === 'string' && lead.leadSource\n ? { leadSource: lead.leadSource }\n : {}),\n ...(lead.address && typeof lead.address === 'object' ? { address: lead.address } : {}),\n ...(typeof lead.company === 'string' && lead.company\n ? { companyName: lead.company }\n : {}),\n },\n })\n /*\n * The one verdict of the door's that is named here. Its other refusals —\n * the band, an address it will not take, its own swallowed error — all\n * read the same from this side: no row to find below, answered as\n * `contact-not-created`. An erasure is different in kind: not a limit the\n * org can lift or a fault in the request but a decision the workspace made\n * about this person, and a caller told \"the band may be full\" would retry\n * against it forever.\n */\n if (captured && 'refused' in captured && captured.refused === 'erased') {\n return { ok: false, reason: 'erased' }\n }\n const contactsRef = await orgDataCollectionForHost(hostId, 'contacts')\n /*\n * The same resolution the capture door just made (AGL-2625): the\n * address index first, so a lead whose address is an ALTERNATE on a\n * merged contact converts into the survivor the capture landed on\n * rather than reading as a contact the band dropped.\n */\n const contactSnapshot = await findContactByEmail(contactsRef, email)\n if (!contactSnapshot) return { ok: false, reason: 'contact-not-created' }\n const contactRef = contactSnapshot.ref\n const contactId = contactSnapshot.id\n const contactData = (contactSnapshot.data() ?? {}) as Record<string, unknown>\n const orgRef = firestore.collection('orgs').doc(orgId)\n /*\n * THE RECORDS BAND (AGL-2611), asked before each record this conversion\n * CREATES. The contact went through the capture door, which refuses at\n * the band on its own; a company and a deal are records of the same\n * band, and on a Free org at its hundred the honest answer is the one\n * the drawers give — refuse, write nothing more, and leave the lead\n * unconverted so a retry after the upgrade finds it where it was. The\n * contact step 1 captured stays: it is one person's record, and a second\n * conversion merges onto it rather than making another. Measured fresh\n * each time because the company created in step 2 is itself a record.\n */\n const bandFull = async () => {\n const room = await crmRecordsQuotaForOrg(org as never, orgRef)\n return !room.allowed\n }\n\n /*\n * WHOSE THE CONTACT IS, now that it exists. The capture wrote the chosen\n * owner, or its pass assigned one to a contact it created; a contact\n * the org already held with no owner has had neither, so the same pass\n * is asked once more — it touches only a record with no owner and\n * answers `unchanged` for one that has — and a member actor is the last\n * resort. A colleague the converter picked is told; the lead's existing\n * owner and the caller are not, having chosen for themselves.\n */\n let ownerUid = readContactFacet(contactData, group.groupId).ownerUid ?? ''\n if (!ownerUid) {\n const assigned = await assignOwnerForCapture({\n hostId,\n contactId,\n email,\n source: 'manual',\n actorUid: actor.kind === 'member' ? actor.uid : null,\n })\n if (assigned.outcome !== 'none') ownerUid = assigned.ownerUid\n }\n if (!ownerUid && actor.kind === 'member') {\n ownerUid = actor.uid\n await contactRef.update({\n [contactFacetPath(group.groupId, 'ownerUid')]: ownerUid,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The owner is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contactRef, 'contacts')\n } else if (ownerUid && pickedOwner && pickedOwner === ownerUid) {\n await notifyRecordAssigned({\n hostId,\n orgId,\n ownerUid,\n actorUid: actor.kind === 'member' ? actor.uid : null,\n record: { kind: 'contact', id: contactId },\n who: leadName || email,\n })\n }\n\n /*==========================================\n * 2. THE COMPANY — linked, found by domain, or created.\n *=========================================*/\n let companyId: string | undefined\n if (requestedCompanyId) {\n const companySnapshot = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .doc(requestedCompanyId)\n .get()\n if (!companySnapshot.exists) return { ok: false, reason: 'unknown-company' }\n companyId = requestedCompanyId\n } else if (createCompany) {\n /*\n * FIND BEFORE CREATE. The NAME first (AGL-3233): a lead names its\n * company as text, and a company the org already files under that\n * name is the account, whatever domain it carries. Then the domain,\n * the key two contacts at one business share — a second company\n * document for `acme.com` would split the account the key exists to\n * join. Only a company the caller can see counts as found — see\n * `readableTokens`.\n */\n const visibleAmong = (\n snapshots: readonly FirebaseFirestore.QueryDocumentSnapshot[],\n ): string | undefined =>\n snapshots.find((snapshot) => {\n const tokens = snapshot.get('visibleTo')\n return (\n Array.isArray(tokens) &&\n tokens.some((token) => readableTokens.has(String(token)))\n )\n })?.id\n const byName = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('nameLower', '==', createCompany.name.toLowerCase())\n .limit(5)\n .get()\n companyId = visibleAmong(byName.docs)\n if (!companyId && createCompany.domain) {\n const byDomain = await orgRef\n .collection(CRM_COLLECTIONS.companies)\n .where('domain', '==', createCompany.domain)\n .limit(5)\n .get()\n companyId = visibleAmong(byDomain.docs)\n }\n if (!companyId) {\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const company: Record<string, unknown> = {\n ...nameSearchFields(createCompany.name),\n ...(createCompany.domain ? { domain: createCompany.domain } : {}),\n ...(ownerUid ? { ownerUid } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await orgRef.collection(CRM_COLLECTIONS.companies).add({\n ...company,\n // What the Companies list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('companies', company),\n })\n companyId = created.id\n }\n }\n if (companyId) {\n /*\n * The one link writer (AGL-2613): the holder's facet field the contact\n * record reads, the queryable top-level mirror the company's contacts\n * card filters on, and the company's contacts count — planned from\n * the row as the capture left it, so a person the org already held\n * under another company is MOVED rather than counted twice.\n */\n await writeContactCompanyLink({\n firestore,\n contactRef,\n contact: contactData,\n companiesRef: orgRef.collection(CRM_COLLECTIONS.companies),\n groupId: group.groupId,\n companyId,\n })\n }\n\n /*==========================================\n * 3. THE DEAL — in the default pipeline, seeded if the org has none.\n *=========================================*/\n let dealId: string | undefined\n if (deal) {\n const pipelinesRef = orgRef.collection(CRM_COLLECTIONS.pipelines)\n let pipelineId: string\n let pipeline: Pick<CrmPipeline, 'stages'>\n const defaults = await pipelinesRef\n .where('isDefault', '==', true)\n .limit(1)\n .get()\n if (!defaults.empty) {\n pipelineId = defaults.docs[0].id\n pipeline = defaults.docs[0].data() as CrmPipeline\n } else {\n /*\n * The deals section's own read shape, so the two agree on which\n * pipeline \"the org's pipeline\" is when none is flagged default: the\n * first by document id of a bounded window. Seeding a SECOND pipeline\n * here while one exists unflagged would leave the merchant with two\n * boards and their deals split between them.\n */\n const any = await pipelinesRef\n .orderBy(FieldPath.documentId())\n .limit(20)\n .get()\n if (!any.empty) {\n pipelineId = any.docs[0].id\n pipeline = any.docs[0].data() as CrmPipeline\n } else {\n // Exactly what the Deals section seeds: one `Sales` pipeline\n // carrying a COPY of the default stages, flagged default.\n const seeded: Omit<CrmPipeline, 'createdAt' | 'updatedAt'> = {\n name: 'Sales',\n stages: [...DEFAULT_DEAL_STAGES],\n isDefault: true,\n visibleTo,\n hostId,\n }\n const created = await pipelinesRef.add({\n ...seeded,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n })\n pipelineId = created.id\n pipeline = seeded\n }\n }\n const stage = stageForNewDeal(pipeline, deal.stageId)\n if (!stage) return { ok: false, reason: 'no-stages' }\n if (await bandFull()) return { ok: false, reason: 'band-full' }\n const dealRecord: Record<string, unknown> = {\n title: deal.title,\n titleLower: deal.title.toLowerCase(),\n pipelineId,\n stageId: stage.id,\n status: dealStatusForStage(stage),\n ...(deal.amountCents !== null ? { amountCents: deal.amountCents } : {}),\n currency: deal.currency,\n stageChangedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n contactId,\n ...(companyId ? { companyId } : {}),\n visibleTo,\n hostId,\n createdByUid: actor.uid,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n const created = await orgRef.collection(CRM_COLLECTIONS.deals).add({\n ...dealRecord,\n // What the Deals list searches and filters by (AGL-3321).\n ...crmNewRecordListFields('deals', dealRecord),\n })\n dealId = created.id\n }\n\n /*==========================================\n * 4. THE LEAD — stamped last, once everything it names exists.\n *=========================================*/\n const stamp: LeadConversionStamp = {\n status: 'qualified',\n convertedContactId: contactId,\n convertedAtMs: now,\n ...(ownerUid ? { ownerUid } : {}),\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n }\n await leadRef.update({ ...stamp, updatedAt: FieldValue.serverTimestamp() })\n\n /*\n * WHAT THE LEAD HANDS OVER (AGL-3233): its activities and tasks gain the\n * contact, and every plugin with a listener — Sequences, for an\n * enrollment naming the lead — re-points its own records. After the\n * stamp, so a listener reading the lead finds the conversion on it; it\n * never throws.\n */\n await handOffLeadRecords({\n firestore,\n orgId,\n hostId,\n leadId,\n contactId,\n email,\n by: actor.kind === 'member' ? 'member' : 'api',\n })\n\n /*\n * The audit line, from the function that did the work (AGL-2622): the\n * conversion is one act however many records it opened, so it is one\n * entry, on the lead, in the feed of the site that holds the lead. A\n * repeat call answered above with the ids it already had wrote nothing\n * and logs nothing.\n */\n await logHostActivity(\n hostId,\n {\n uid: actor.uid,\n email: actor.email ?? null,\n ...(actor.apiKeyName ? { apiKeyName: actor.apiKeyName } : {}),\n },\n 'Converted lead',\n { type: 'lead', id: leadId, name: String(lead['name'] ?? '') || email },\n )\n\n return {\n ok: true,\n contactId,\n ...(companyId ? { companyId } : {}),\n ...(dealId ? { dealId } : {}),\n alreadyConverted: false,\n }\n}\n"],"names":["consentGroupDisclosureKey","consentGroupScope","CRM_COLLECTIONS","crmScopeTokens","DEFAULT_DEAL_STAGES","grantedUnderConsentGroup","nameSearchFields","contactFacetPath","normalizeContactEmail","ORG_SCOPE_TOKEN","readContactFacet","crmNewRecordListFields","readMarketingBasis","soloConsentGroup","consentGroupForSite","crmRecordsQuotaForOrg","findContactByEmail","logHostActivity","orgDataCollectionForHost","restampCrmListFieldsAt","writeContactCompanyLink","FieldPath","FieldValue","assignOwnerForCapture","notifyRecordAssigned","captureHostContact","handOffLeadRecords","stageForNewDeal","pipeline","requestedStageId","requested","stages","sort","a","b","order","length","find","stage","id","undefined","kind","dealStatusForStage","convertHostLead","input","leadSnapshot","contactSnapshot","actor","lead","firestore","hostId","orgId","org","leadId","createCompany","deal","requestedCompanyId","String","companyId","trim","leadRef","collection","doc","get","exists","ok","reason","data","convertedContactId","contactId","dealId","alreadyConverted","email","group","visibleTo","readableTokens","Set","pickedOwner","ownerUid","chosenOwner","now","Date","leadName","leadTags","Array","isArray","tags","map","leadConsent","leadDisclosure","captured","name","source","interaction","summary","refId","basis","marketingConsent","disclosedConsentGroup","facet","lifecycleStage","phone","jobTitle","leadSource","address","company","companyName","refused","contactsRef","contactRef","ref","contactData","orgRef","bandFull","room","allowed","groupId","assigned","actorUid","uid","outcome","update","updatedAt","serverTimestamp","record","who","companySnapshot","companies","visibleAmong","snapshots","snapshot","tokens","some","token","has","byName","where","toLowerCase","limit","docs","domain","byDomain","createdByUid","createdAt","created","add","contact","companiesRef","pipelinesRef","pipelines","pipelineId","defaults","empty","any","orderBy","documentId","seeded","isDefault","stageId","dealRecord","title","titleLower","status","amountCents","currency","stageChangedAtMs","deals","stamp","convertedAtMs","by","apiKeyName","type"],"mappings":"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"}
|
|
@@ -85,11 +85,23 @@ export interface CollectionEntrySummary {
|
|
|
85
85
|
export declare function isDueScheduled(value: FirebaseFirestore.DocumentData): boolean;
|
|
86
86
|
/**
|
|
87
87
|
* A scheduled entry still waiting on its time: not due yet, and not refused.
|
|
88
|
-
*
|
|
89
|
-
* read holding one would withhold the render
|
|
90
|
-
* {@link LiveEntriesRead.pendingSchedule}.
|
|
88
|
+
* A render is the backstop that publishes a content entry the beat has not
|
|
89
|
+
* reached, so a cache that stored a read holding one would withhold the render
|
|
90
|
+
* that notices it come due — see {@link LiveEntriesRead.pendingSchedule}.
|
|
91
91
|
*/
|
|
92
92
|
export declare function isPendingScheduled(value: FirebaseFirestore.DocumentData): boolean;
|
|
93
|
+
/**
|
|
94
|
+
* When this entry's schedule comes due, in epoch ms — or null when it holds
|
|
95
|
+
* no schedule to act on: not `scheduled`, already refused, or carrying no
|
|
96
|
+
* `publishAt` (AGL-3340).
|
|
97
|
+
*
|
|
98
|
+
* The beat orders its probe by this instant. A refused schedule keeps
|
|
99
|
+
* `status: 'scheduled'` forever (see {@link ENTRY_SCHEDULE_SKIPPED}), so
|
|
100
|
+
* reading one as due would make every beat re-probe for work that will never
|
|
101
|
+
* be done. Seconds, like {@link isDueScheduled}, so the two agree about the
|
|
102
|
+
* instant an entry comes due.
|
|
103
|
+
*/
|
|
104
|
+
export declare function entryScheduleDueAtMs(value: FirebaseFirestore.DocumentData): number | null;
|
|
93
105
|
/**
|
|
94
106
|
* Is this host's plan allowed to publish on a schedule? (AGL-471 shape.)
|
|
95
107
|
*
|
|
@@ -123,7 +135,10 @@ export declare function scheduledPublishingPermission(hostId: string): Promise<S
|
|
|
123
135
|
/**
|
|
124
136
|
* Scheduled entries (AGL-123) go live lazily like AGL-61: a due
|
|
125
137
|
* `publishAt` counts as published for this render, and the doc is flipped
|
|
126
|
-
* to `published` fail-open so the state becomes durable.
|
|
138
|
+
* to `published` fail-open so the state becomes durable. The tenant's
|
|
139
|
+
* `apply-entry-schedules` beat makes the same flip at the entry's time and
|
|
140
|
+
* drops the pages that list it (AGL-3340); this render-time half stays as the
|
|
141
|
+
* backstop for a beat that is down or has not reached the entry yet.
|
|
127
142
|
*
|
|
128
143
|
* PLAN GATE (AGL-471). `scheduledPublishing` is a Business entitlement, and
|
|
129
144
|
* until now nothing on the entry path checked it: the console let any plan
|
|
@@ -140,6 +155,28 @@ export declare function scheduledPublishingPermission(hostId: string): Promise<S
|
|
|
140
155
|
* points at can never disagree about which entries exist.
|
|
141
156
|
*/
|
|
142
157
|
export declare function isLive(value: FirebaseFirestore.DocumentData, permission: SchedulePermission): boolean;
|
|
158
|
+
/**
|
|
159
|
+
* What {@link flipDueEntry} did to one entry.
|
|
160
|
+
*
|
|
161
|
+
* `published` is the only outcome that changes what a visitor sees, so it is
|
|
162
|
+
* the only one a caller drops pages for. `refused` changes the stored state
|
|
163
|
+
* and nothing on the site — a refused entry was never live.
|
|
164
|
+
*/
|
|
165
|
+
export type EntryScheduleOutcome = 'published' | 'refused' | 'unchanged' | 'failed';
|
|
166
|
+
/**
|
|
167
|
+
* Apply one due entry schedule outside a render (AGL-3340) — the executor the
|
|
168
|
+
* `apply-entry-schedules` beat runs, and the same one a render runs, so the
|
|
169
|
+
* two cannot come to disagree about the plan gate or the fields a flip
|
|
170
|
+
* writes.
|
|
171
|
+
*
|
|
172
|
+
* Asks the plan only for an entry that is actually due, as every render path
|
|
173
|
+
* does. Guarded on the snapshot's own `updateTime`: a schedule edited after
|
|
174
|
+
* the beat read it is left for the next beat, which reads it fresh.
|
|
175
|
+
*/
|
|
176
|
+
export declare function applyDueEntrySchedule(options: {
|
|
177
|
+
hostId: string;
|
|
178
|
+
entry: Pick<FirebaseFirestore.DocumentSnapshot, 'ref' | 'data' | 'updateTime'>;
|
|
179
|
+
}): Promise<EntryScheduleOutcome>;
|
|
143
180
|
export interface CollectionContent {
|
|
144
181
|
/**
|
|
145
182
|
* The zone this site's dates read in (AGL-3237), carried on the CONTENT so
|