@aglyn/plugins-crm 1.0.0-beta.222 → 1.0.0-beta.223
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +13 -13
- package/src/lib/components/crm-next-activity-column.js +1 -1
- package/src/lib/components/crm-next-activity-column.js.map +1 -1
- package/src/lib/components/reports/pipeline-card.js +2 -1
- package/src/lib/components/reports/pipeline-card.js.map +1 -1
- package/src/lib/declarations.server.js +5 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/contact-record.js +2 -1
- package/src/lib/model/contact-record.js.map +1 -1
- package/src/lib/model/crm-next-activity.d.ts +107 -0
- package/src/lib/model/crm-next-activity.js +140 -0
- package/src/lib/model/crm-next-activity.js.map +1 -0
- package/src/lib/model/crm-recipes.js +33 -18
- package/src/lib/model/crm-recipes.js.map +1 -1
- package/src/lib/model/crm-task-reminders.d.ts +152 -0
- package/src/lib/model/crm-task-reminders.js +161 -0
- package/src/lib/model/crm-task-reminders.js.map +1 -0
- package/src/lib/model/next-activity.d.ts +1 -1
- package/src/lib/model/next-activity.js.map +1 -1
- package/src/lib/server/api-v1/crm-tasks.js +4 -2
- package/src/lib/server/api-v1/crm-tasks.js.map +1 -1
- package/src/lib/server/assign-contact-owner.d.ts +27 -1
- package/src/lib/server/assign-contact-owner.js +136 -30
- package/src/lib/server/assign-contact-owner.js.map +1 -1
- package/src/lib/server/automation-steps.d.ts +2 -1
- package/src/lib/server/automation-steps.js +210 -13
- package/src/lib/server/automation-steps.js.map +1 -1
- package/src/lib/server/capture-contact.d.ts +17 -0
- package/src/lib/server/capture-contact.js +31 -6
- package/src/lib/server/capture-contact.js.map +1 -1
- package/src/lib/server/crm-email-activity.d.ts +118 -0
- package/src/lib/server/crm-email-activity.js +148 -0
- package/src/lib/server/crm-email-activity.js.map +1 -0
- package/src/lib/server/crm-inbound-email.js +1 -1
- package/src/lib/server/crm-inbound-email.js.map +1 -1
- package/src/lib/server/crm-next-activity.d.ts +74 -0
- package/src/lib/server/crm-next-activity.js +194 -0
- package/src/lib/server/crm-next-activity.js.map +1 -0
- package/src/lib/server/email-send.js +2 -1
- package/src/lib/server/email-send.js.map +1 -1
- package/src/lib/server/merge-contacts.js +1 -1
- package/src/lib/server/merge-contacts.js.map +1 -1
- package/src/lib/server/next-activity-routes.js +2 -1
- package/src/lib/server/next-activity-routes.js.map +1 -1
- package/src/lib/server/recipe-routes.js +7 -1
- package/src/lib/server/recipe-routes.js.map +1 -1
- package/src/lib/server/record-timeline.js +26 -2
- package/src/lib/server/record-timeline.js.map +1 -1
- package/src/lib/server/task-reminders-route.js +2 -1
- package/src/lib/server/task-reminders-route.js.map +1 -1
- package/src/lib/server/task-routes.js +4 -2
- package/src/lib/server/task-routes.js.map +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/automation-steps.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 buildCrmEmailActivity,\n checkEntitlement,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_TAG_MAX_LENGTH,\n contactFacetPath,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n type CrmActivity,\n type CrmActivityDirection,\n crmActivityDirection,\n type CrmActivityLink,\n type CrmTask,\n type CrmTaskPriority,\n crmEmailDeliveryTags,\n crmScopeTokens,\n crmTaskLabelsForNew,\n crmTaskListFields,\n crmTaskReminderAfterEdit,\n normalizeContactEmail,\n parseCrmMemberRef,\n planLabelGrantingFeature,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport type {\n PluginRecordEmailPrepareRequest,\n PluginRecordPreparedEmail,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type {\n ServerStepAnswer,\n ServerStepRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-server-steps'\nimport {\n consentGroupForSite,\n countCrmActivitiesForRecord,\n firebaseAdmin,\n newCrmActivityRef,\n orgDataQueryForHost,\n recomputeCrmNextTaskAt,\n restampCrmListFieldsAt,\n writeCrmEmailActivity,\n} from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: the specs substitute the barrel wholesale, and\n// the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\n// The leaf, not the runtime barrel: the specs substitute it.\nimport {\n OWNER_ASSIGNMENT_REFUSALS,\n reassignContactOwner,\n} from './assign-contact-owner'\nimport type { InteractionStepGuard } from '@aglyn/aglyn/app-utils/site-interactions'\nimport type { ContactLifecycleStage, CrmActivityKind, CrmTaskKind } from '@aglyn/aglyn/app-utils/crm-kinds'\nimport type { CRM_STEP_TYPES } from '../constants/bundle-common'\nimport { readCrmTaskPicklists } from './read-picklist'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\n\n/**\n * THE AUTOMATION STEPS THAT WRITE THE CRM (AGL-2605), run for the automation\n * engine through the platform's server-step seam (`plugin-server-steps`,\n * AGL-3080): `setContactStage`, `addContactTag`, `assignContactOwner`,\n * `createCrmTask` and `logCrmActivity`.\n *\n * The engine keeps the step's guard, the run's order, its history and the\n * nesting cap; this answers what the step did. A refusal is ANSWERED as\n * `error`, never thrown, and its words are the line the run history carries —\n * the same words the engine wrote when these steps were its own.\n *\n * The five share one shape no other step has: each begins by finding THE\n * PERSON the event is about, and each writes either inside the site's facet on\n * that person or a record stamped with the site's scope. One resolver and one\n * scope expression, used five times, rather than five copies drifting apart.\n *\n * ## The person is the event's, never the step's\n *\n * A step carries no contact reference. The contact is whoever the event\n * names — by `contactId` when the emitting door knew the document, and by\n * `email` otherwise, which is what every pre-CRM event carries. Both lookups\n * are scoped to what THIS site may see: a document the id names but the site\n * cannot read is treated as absent, exactly as the scoped query would have\n * treated it, so an event replayed against the wrong site reaches nobody.\n *\n * ## A missing person is a reported no-op\n *\n * Nothing is written and the step's error names the reason, which lands in\n * the run summary beside the trigger — the same place \"why didn't it fire?\"\n * is answered for every other step. Silence here would make a mis-wired\n * trigger (a form with no email field) indistinguishable from a working one.\n *\n * ## The plan gate is the CRM's\n *\n * Every step is refused on a workspace whose plan does not carry the CRM,\n * into the run history with the tier that carries it, so a Free workspace\n * whose flow names a CRM step reads why the step did nothing rather than a\n * log that says it ran. The org document is the one the run's gate already\n * read, handed over with the step, so the check costs no read.\n *\n * The same module answers the record timeline's `prepareEmail` (AGL-2615): an\n * automation's email addressed to the contact the event is about is filed on\n * that contact's timeline — see {@link prepareCrmRecordEmail}.\n */\n\n/** One of the steps this module runs. */\nexport type CrmStepType = (typeof CRM_STEP_TYPES)[number]\n\n/**\n * A CRM step, as it is stored: the CRM's own shapes of the steps it runs. The\n * automation editor writes them and checks them; `apps/console/specs/\n * crm-automation-steps.spec.ts` runs what it writes through this module.\n */\nexport type CrmAutomationStep = (\n | { type: 'setContactStage'; lifecycleStage: ContactLifecycleStage }\n | { type: 'addContactTag'; tag: string }\n | { type: 'assignContactOwner'; ownerUid?: string; ownerEmail?: string; roundRobin?: boolean }\n | {\n type: 'createCrmTask'\n title: string\n kind: CrmTaskKind\n /** `normal` when absent — the steps written before priorities were (AGL-3517). */\n priority?: CrmTaskPriority\n dueInDays: number\n assigneeUid?: string\n assigneeEmail?: string\n }\n | {\n type: 'logCrmActivity'\n kind: CrmActivityKind\n body: string\n /** Which way a call or an email went (AGL-3517). */\n direction?: CrmActivityDirection\n }\n) & { when?: InteractionStepGuard | null } & { type: CrmStepType }\n\n/** The site, the plan and the org a step or an email is answered for. */\ninterface CrmStepEnv {\n hostId: string\n /** The owning org's billing doc, already read by the run's gate. */\n org: unknown\n orgId: string | null\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\ninterface ResolvedContact {\n id: string\n ref: FirebaseFirestore.DocumentReference\n data: Record<string, unknown>\n}\n\n/**\n * The contact an event names, as this site may see it.\n *\n * Id first, because a CRM event carries the document the door just wrote and\n * a lookup by id is a read, not a query. The address second, through the\n * org's address index narrowed to this site (AGL-2633), for every event that\n * predates the CRM and knows only who filled in the form — so an address a\n * merge folded into another record still reaches the person who now holds it.\n */\nasync function resolveEventContact(\n hostId: string,\n named: { contactId?: unknown; email?: unknown },\n): Promise<ResolvedContact | null> {\n const { ref: contactsRef } = await orgDataQueryForHost(hostId, 'contacts')\n const contactId = String(named.contactId ?? '').trim()\n if (contactId) {\n const doc = await contactsRef.doc(contactId).get()\n if (doc.exists && visibleToHost(doc.get('visibleTo'), hostId)) {\n return { id: doc.id, ref: doc.ref, data: doc.data() ?? {} }\n }\n }\n const hit = await findContactByEmail(contactsRef, named.email, { hostId })\n return hit ? { id: hit.id, ref: hit.ref, data: hit.data() ?? {} } : null\n}\n\n/**\n * The uid a step names for somebody on the team — an owner, an assignee —\n * resolved against the org's roster, whichever way the step named them.\n *\n * A step names a member by uid when a picker wrote it and by address when a\n * person typed it, and the editor's one text field accepts either — so both\n * fields are read through `parseCrmMemberRef`, and an address typed into the\n * uid slot or a uid typed into the address slot still names the person.\n *\n * Both are RESOLVED, not trusted. `orgs/{orgId}/members/{uid}` is keyed by the\n * uid, so a uid costs one document read and must exist: `ownerUid` and\n * `assigneeUid` are fields every reader resolves as a member, and a stranger's\n * uid in one is a task nobody on the team can find. An address is matched on\n * the roster's own `email` field, and two production paths create a member\n * document WITHOUT one (a host-access re-grant, and an add whose auth record\n * carried none) — such a member is named by uid, which is the reason the\n * editor takes one.\n *\n * The roster is the only directory consulted. A project-level Auth lookup by\n * address would resolve people who are not on this organization at all\n * (AGL-1122). A reference that resolves neither way is an error, not a stored\n * string.\n */\nasync function resolveMemberUid(\n orgId: string | null,\n named: { uid?: string; email?: string },\n role: 'owner' | 'assignee',\n): Promise<{ uid: string; detail: string } | { error: string }> {\n const ref = parseCrmMemberRef(named.uid) ?? parseCrmMemberRef(named.email)\n if (!ref) return { error: `no ${role} named on the step` }\n if (!orgId) return { error: 'this site has no organization' }\n const members = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n if (ref.kind === 'uid') {\n const member = await members.doc(ref.uid).get()\n if (member.exists) return { uid: ref.uid, detail: ref.uid }\n return { error: `no team member with the id ${ref.uid}` }\n }\n const byField = (await members.where('email', '==', ref.email).limit(1).get()).docs[0]\n if (byField) return { uid: byField.id, detail: ref.email }\n return { error: `no team member with the address ${ref.email}` }\n}\n\n/**\n * Runs one CRM step for the person the event names: the executor the CRM\n * registers on the server-step seam for every type in `CRM_STEP_TYPES`.\n *\n * Never throws for a plan without the CRM, a missing person, an unknown owner\n * or a site with no org — those are answered as `error` so the run records\n * them; a Firestore failure propagates, and the engine records it the same\n * way.\n */\nexport async function runCrmAutomationStep(\n request: ServerStepRequest,\n nowMs = Date.now(),\n): Promise<ServerStepAnswer> {\n if (!checkEntitlement(request.org as Parameters<typeof checkEntitlement>[0], CRM_SUITE_FEATURE)) {\n return { error: `CRM steps require the ${planLabelGrantingFeature(CRM_SUITE_FEATURE)} plan` }\n }\n const env: CrmStepEnv = { hostId: request.hostId, org: request.org, orgId: request.orgId }\n const step = request.step as unknown as CrmAutomationStep\n const actionId = request.run.id\n const { hostId } = env\n const contact = await resolveEventContact(hostId, request.payload)\n if (!contact) {\n const named = String(request.payload['contactId'] ?? request.payload['email'] ?? '').trim()\n return {\n error: named\n ? `no contact this site can see for ${named}`\n : 'the event names no contact — no contactId or email in its payload',\n }\n }\n /*\n * THE HOLDER whose facet the write addresses — the site's consent group,\n * which is the site alone unless the org declared the site one of a set\n * that presents as one sender. Same resolution the capture doors use, so a\n * stage set here lands in the facet the console reads.\n */\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(contact.data, group.groupId)\n const email = String(contact.data['email'] ?? '')\n\n if (step.type === 'setContactStage') {\n const previous = facet.lifecycleStage ?? ''\n const label = CONTACT_LIFECYCLE_STAGE_LABELS[step.lifecycleStage]\n /*\n * A stage set to what it already is changes nothing and ANNOUNCES\n * nothing. The write is skipped so the row's `updatedAt` does not move for\n * a non-event, and the absent emit is what stops an action that sets the\n * stage it listens for from re-running on its own write — the engine's\n * nesting cap bounds that chain, this ends it at once.\n */\n if (previous === step.lifecycleStage) {\n return { detail: `${label} (already)` }\n }\n await contact.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: step.lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contact.ref, 'contacts')\n /*\n * A stage set by an automation IS a stage change, and the action\n * listening for it must run — but through the engine's nesting cap, so\n * the event is answered back for the engine to raise, never raised here.\n */\n return {\n detail: label,\n emit: {\n event: 'contactStageChanged',\n payload: {\n contactId: contact.id,\n email,\n lifecycleStage: step.lifecycleStage,\n previousStage: previous,\n },\n },\n }\n }\n\n if (step.type === 'addContactTag') {\n const tag = String(step.tag ?? '')\n .trim()\n .slice(0, CONTACT_TAG_MAX_LENGTH)\n if (!tag) return { error: 'the step has no tag' }\n // `arrayUnion`, so the tag a merchant already applied by hand is not\n // duplicated and the tags beside it are kept.\n await contact.ref.update({\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(tag),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The tags are what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contact.ref, 'contacts')\n return { detail: tag }\n }\n\n if (step.type === 'assignContactOwner') {\n /*\n * Through the one server assignment (AGL-2618), in both of the step's\n * modes, rather than a facet write of its own: the helper is what moves\n * the round-robin pointer inside the transaction that writes the owner,\n * mirrors the owner onto the site's lead, and tells the new owner. A\n * member named on the step is still resolved here — by address against\n * the roster, as before — and handed over by uid; the helper checks the\n * roster document again, which is one read and the same answer.\n */\n let assign: { memberUid: string } | { roundRobin: true }\n let named = 'round robin'\n if (step.roundRobin === true) {\n assign = { roundRobin: true }\n } else {\n const owner = await resolveMemberUid(\n env.orgId,\n { uid: step.ownerUid, email: step.ownerEmail },\n 'owner',\n )\n if ('error' in owner) return { error: owner.error }\n assign = { memberUid: owner.uid }\n named = owner.detail\n }\n const verdict = await reassignContactOwner({\n hostId,\n contactId: contact.id,\n email,\n assign,\n })\n if (verdict.outcome === 'none') {\n return { error: OWNER_ASSIGNMENT_REFUSALS[verdict.reason] }\n }\n if (verdict.outcome === 'unchanged') {\n return { detail: `${named} (already)` }\n }\n return {\n detail: step.roundRobin === true ? `round robin → ${verdict.ownerUid}` : named,\n }\n }\n\n // The two record creators: a task and an activity are documents of their\n // own beside the contact, stamped with the scope a contact captured on this\n // site would carry — `crmScopeTokens` is the create path's own expression,\n // so a record an automation made is visible to exactly the sites a record a\n // person made would be.\n if (!env.orgId) return { error: 'this site has no organization' }\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(env.orgId)\n const visibleTo = crmScopeTokens((env.org ?? null) as Record<string, unknown> | null, group)\n const links = {\n contactId: contact.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n }\n\n if (step.type === 'createCrmTask') {\n const title = String(step.title ?? '')\n .trim()\n .slice(0, 200)\n if (!title) return { error: 'the task has no title' }\n const dueInDays = Math.max(0, Math.round(Number(step.dueInDays) || 0))\n /*\n * The assignee the step names, else the person who OWNS the contact, else\n * nobody. A follow-up task belongs to whoever holds the relationship, and\n * an automation that had to name one person for every contact it touches\n * would assign the whole list to one rep. A named assignee who cannot be\n * resolved is an error rather than a fallback to the owner: the author\n * named somebody on purpose.\n */\n let assignee = facet.ownerUid ?? ''\n if (step.assigneeUid?.trim() || step.assigneeEmail?.trim()) {\n const named = await resolveMemberUid(\n env.orgId,\n { uid: step.assigneeUid, email: step.assigneeEmail },\n 'assignee',\n )\n if ('error' in named) return { error: named.error }\n assignee = named.uid\n }\n const dueAtMs = nowMs + dueInDays * DAY_MS\n const priority: CrmTaskPriority =\n step.priority === 'high' || step.priority === 'low' ? step.priority : 'normal'\n // The org's labels for the meanings the step names (AGL-3517).\n const labels = crmTaskLabelsForNew(\n await readCrmTaskPicklists(firebaseAdmin.app().firestore(), env.orgId),\n { kind: step.kind, priority },\n )\n const task: CrmTask = {\n title,\n kind: step.kind,\n priority,\n status: 'open',\n ...labels,\n dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs, previous: null }),\n ...(assignee ? { assigneeUid: assignee } : {}),\n createdByUid: '',\n sourceActionId: actionId,\n ...links,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n await orgRef.collection(CRM_COLLECTIONS.tasks).add({ ...task, ...crmTaskListFields(task) })\n // The contact and company the task names carry `nextTaskAtMs` (AGL-2661);\n // a figure that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firebaseAdmin.app().firestore(), env.orgId, [links]).catch(\n (error: unknown) => {\n console.error('[crm] next activity could not be recomputed', env.orgId, error)\n },\n )\n return { detail: title.slice(0, 60) }\n }\n\n if (step.type === 'logCrmActivity') {\n const body = String(step.body ?? '')\n .trim()\n .slice(0, 2000)\n if (!body) return { error: 'the activity has no body' }\n /*\n * The per-record ceiling (AGL-2611), and this step is the writer it exists\n * for: a flow that logs on every page view fills one person's log in an\n * afternoon. Refused into the run history as an error, the way an\n * unresolvable assignee is, so the merchant reads why the flow stopped\n * writing rather than finding a log that silently stopped.\n */\n const logged = await countCrmActivitiesForRecord(orgRef, links)\n if (!crmActivityLogHasRoom(logged)) {\n return { error: CRM_ACTIVITY_LOG_FULL_MESSAGE }\n }\n const direction = crmActivityDirection(step.kind, step.direction)\n const activity: CrmActivity = {\n kind: step.kind,\n body,\n ...(direction ? { direction } : {}),\n atMs: nowMs,\n byUid: '',\n sourceActionId: actionId,\n ...links,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n await orgRef.collection(CRM_COLLECTIONS.activities).add(activity)\n return { detail: step.kind }\n }\n\n return { error: `unknown CRM step \"${(step as { type: string }).type}\"` }\n}\n\n/**\n * Whether — and where — an email a plugin is about to send lands on a\n * timeline: the CRM's answer to the record timeline's `prepareEmail`.\n *\n * An automation's `sendEmail` step is not a CRM step: it mails whatever\n * address the event carries, to a person who may be nobody the CRM knows. It\n * earns a row on exactly one condition, that the message is ADDRESSED TO THE\n * CONTACT the sender means — the person the request's link finds, at the\n * address the row holds. A welcome sequence to a new contact is that; an\n * internal alert routed to a merchant's own address through `toField` is not,\n * and a row for it would put the merchant's inbox on a customer's history.\n *\n * Prepared ahead of the send, for the reason the console route mints its id\n * first: the delivery webhook has nothing but the tags on the message to find\n * the row with. Nothing is written until `file` is called, once the provider\n * accepted the message. A workspace whose plan does not carry the CRM, a\n * record at the activity ceiling and a site with no org earn no row — the\n * message still goes, because the ceiling bounds the log and not the mail.\n *\n * **Never throws**, and neither does `file`. The row is bookkeeping beside a\n * send, and a lookup that fails must not become a message that never left —\n * the posture every meter beside a send takes. A failure is logged and the\n * message goes out untagged.\n */\nexport async function prepareCrmRecordEmail(\n request: PluginRecordEmailPrepareRequest,\n): Promise<PluginRecordPreparedEmail | null> {\n const orgId = String(request.orgId ?? '').trim()\n const hostId = String(request.hostId ?? '').trim()\n if (!orgId || !hostId) return null\n const address = normalizeContactEmail(request.to)\n if (!address) return null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n // The org the caller already read, or the one read here once.\n const org =\n request.org !== undefined ? request.org : ((await orgRef.get()).data() ?? null)\n if (!checkEntitlement(org as Parameters<typeof checkEntitlement>[0], CRM_SUITE_FEATURE)) {\n return null\n }\n const contact = await resolveEventContact(hostId, request.link)\n if (!contact || normalizeContactEmail(contact.data['email']) !== address) {\n return null\n }\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(contact.data, group.groupId)\n const link: CrmActivityLink = {\n contactId: contact.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return null\n }\n const ref = newCrmActivityRef(firestore, orgId)\n const visibleTo = crmScopeTokens((org ?? null) as Record<string, unknown> | null, group)\n return {\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: ref.id }),\n /*\n * The same shape the console route logs — `buildCrmEmailActivity` is the\n * one builder — with the sender's id as its source and nobody as its\n * author. Never throws: the message has left, and a row that could not\n * be written is a gap on the timeline rather than a failed send.\n */\n async file(sent) {\n try {\n await writeCrmEmailActivity(\n ref,\n buildCrmEmailActivity({\n subject: sent.subject,\n body: sent.body,\n to: normalizeContactEmail(sent.to) ?? sent.to,\n atMs: sent.atMs ?? Date.now(),\n byUid: '',\n sourceActionId: sent.sourceRef,\n link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n console.error('[crm] automation email activity write failed', ref.id, error)\n }\n },\n }\n } catch (error) {\n console.error('[crm] automation email activity could not be prepared', hostId, error)\n return null\n }\n}\n"],"names":["buildCrmEmailActivity","checkEntitlement","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_TAG_MAX_LENGTH","contactFacetPath","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmActivityDirection","crmEmailDeliveryTags","crmScopeTokens","crmTaskLabelsForNew","crmTaskListFields","crmTaskReminderAfterEdit","normalizeContactEmail","parseCrmMemberRef","planLabelGrantingFeature","readContactFacet","visibleToHost","consentGroupForSite","countCrmActivitiesForRecord","firebaseAdmin","newCrmActivityRef","orgDataQueryForHost","recomputeCrmNextTaskAt","restampCrmListFieldsAt","writeCrmEmailActivity","findContactByEmail","FieldValue","OWNER_ASSIGNMENT_REFUSALS","reassignContactOwner","readCrmTaskPicklists","CRM_SUITE_FEATURE","DAY_MS","resolveEventContact","hostId","named","hit","ref","contactsRef","contactId","String","trim","doc","get","exists","id","data","email","resolveMemberUid","orgId","role","uid","error","members","app","firestore","collection","kind","member","detail","byField","where","limit","docs","runCrmAutomationStep","request","nowMs","Date","now","contact","env","org","step","actionId","run","payload","group","facet","groupId","type","previous","lifecycleStage","label","update","updatedAt","serverTimestamp","emit","event","previousStage","tag","slice","arrayUnion","assign","roundRobin","owner","ownerUid","ownerEmail","memberUid","verdict","outcome","reason","orgRef","visibleTo","links","companyId","title","dueInDays","Math","max","round","Number","assignee","assigneeUid","assigneeEmail","dueAtMs","priority","labels","task","status","remindAtMs","createdByUid","sourceActionId","createdAt","tasks","add","catch","console","body","logged","direction","activity","atMs","byUid","activities","prepareCrmRecordEmail","address","to","undefined","link","tags","activityId","file","sent","subject","sourceRef"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/automation-steps.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 buildCrmEmailActivity,\n checkEntitlement,\n CONTACT_LIFECYCLE_STAGE_LABELS,\n CONTACT_TAG_MAX_LENGTH,\n contactFacetPath,\n CRM_ACTIVITY_LOG_FULL_MESSAGE,\n CRM_COLLECTIONS,\n crmActivityLogHasRoom,\n type CrmActivity,\n type CrmActivityDirection,\n crmActivityDirection,\n type CrmActivityLink,\n type CrmTask,\n type CrmTaskPriority,\n crmEmailDeliveryTags,\n crmScopeTokens,\n crmTaskLabelsForNew,\n crmTaskListFields,\n normalizeContactEmail,\n parseCrmMemberRef,\n personKey,\n planLabelGrantingFeature,\n readContactFacet,\n visibleToHost,\n} from '@aglyn/aglyn/server'\nimport type {\n PluginRecordEmailPrepareRequest,\n PluginRecordPreparedEmail,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type {\n ServerStepAnswer,\n ServerStepRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-server-steps'\nimport {\n consentGroupForSite,\n countCrmActivitiesForRecord,\n firebaseAdmin,\n orgDataQueryForHost,\n restampCrmListFieldsAt,\n} from '@aglyn/tenant-data-admin'\n// The leaf, not the barrel: the specs substitute the barrel wholesale, and\n// the lookup must reach the real index logic under them.\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\n// The leaf, not the runtime barrel: the specs substitute it.\nimport {\n OWNER_ASSIGNMENT_REFUSALS,\n reassignContactOwner,\n reassignLeadOwner,\n} from './assign-contact-owner'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport type { InteractionStepGuard } from '@aglyn/aglyn/app-utils/site-interactions'\nimport type { ContactLifecycleStage, CrmActivityKind, CrmTaskKind } from '@aglyn/aglyn/app-utils/crm-kinds'\nimport type { CRM_STEP_TYPES } from '../constants/bundle-common'\nimport { readCrmTaskPicklists } from './read-picklist'\nimport { CRM_SUITE_FEATURE } from './suite-gate'\nimport { crmTaskReminderAfterEdit } from '../model/crm-task-reminders'\nimport { recomputeCrmNextTaskAt } from './crm-next-activity'\nimport { newCrmActivityRef, writeCrmEmailActivity } from './crm-email-activity'\n\n/**\n * THE AUTOMATION STEPS THAT WRITE THE CRM (AGL-2605), run for the automation\n * engine through the platform's server-step seam (`plugin-server-steps`,\n * AGL-3080): `setContactStage`, `addContactTag`, `assignContactOwner`,\n * `createCrmTask` and `logCrmActivity`.\n *\n * The engine keeps the step's guard, the run's order, its history and the\n * nesting cap; this answers what the step did. A refusal is ANSWERED as\n * `error`, never thrown, and its words are the line the run history carries —\n * the same words the engine wrote when these steps were its own.\n *\n * The five share one shape no other step has: each begins by finding THE\n * PERSON the event is about, and each writes either inside the site's facet on\n * that person or a record stamped with the site's scope. One resolver and one\n * scope expression, used five times, rather than five copies drifting apart.\n *\n * ## The person is the event's, never the step's\n *\n * A step carries no contact reference. The contact is whoever the event\n * names — by `contactId` when the emitting door knew the document, and by\n * `email` otherwise, which is what every pre-CRM event carries. Both lookups\n * are scoped to what THIS site may see: a document the id names but the site\n * cannot read is treated as absent, exactly as the scoped query would have\n * treated it, so an event replayed against the wrong site reaches nobody.\n *\n * ## A missing person is a reported no-op\n *\n * Nothing is written and the step's error names the reason, which lands in\n * the run summary beside the trigger — the same place \"why didn't it fire?\"\n * is answered for every other step. Silence here would make a mis-wired\n * trigger (a form with no email field) indistinguishable from a working one.\n *\n * ## The plan gate is the CRM's\n *\n * Every step is refused on a workspace whose plan does not carry the CRM,\n * into the run history with the tier that carries it, so a Free workspace\n * whose flow names a CRM step reads why the step did nothing rather than a\n * log that says it ran. The org document is the one the run's gate already\n * read, handed over with the step, so the check costs no read.\n *\n * The same module answers the record timeline's `prepareEmail` (AGL-2615): an\n * automation's email addressed to the contact the event is about is filed on\n * that contact's timeline — see {@link prepareCrmRecordEmail}.\n */\n\n/** One of the steps this module runs. */\nexport type CrmStepType = (typeof CRM_STEP_TYPES)[number]\n\n/**\n * A CRM step, as it is stored: the CRM's own shapes of the steps it runs. The\n * automation editor writes them and checks them; `apps/console/specs/\n * crm-automation-steps.spec.ts` runs what it writes through this module.\n */\nexport type CrmAutomationStep = (\n | { type: 'setContactStage'; lifecycleStage: ContactLifecycleStage }\n | { type: 'addContactTag'; tag: string }\n | { type: 'assignContactOwner'; ownerUid?: string; ownerEmail?: string; roundRobin?: boolean }\n | {\n type: 'createCrmTask'\n title: string\n kind: CrmTaskKind\n /** `normal` when absent — the steps written before priorities were (AGL-3517). */\n priority?: CrmTaskPriority\n dueInDays: number\n assigneeUid?: string\n assigneeEmail?: string\n }\n | {\n type: 'logCrmActivity'\n kind: CrmActivityKind\n body: string\n /** Which way a call or an email went (AGL-3517). */\n direction?: CrmActivityDirection\n }\n) & { when?: InteractionStepGuard | null } & { type: CrmStepType }\n\n/** The site, the plan and the org a step or an email is answered for. */\ninterface CrmStepEnv {\n hostId: string\n /** The owning org's billing doc, already read by the run's gate. */\n org: unknown\n orgId: string | null\n}\n\nconst DAY_MS = 24 * 60 * 60 * 1000\n\ninterface ResolvedContact {\n id: string\n ref: FirebaseFirestore.DocumentReference\n data: Record<string, unknown>\n}\n\n/**\n * The person a CRM step acts on: a contact, or — when the workspace holds\n * none for them — the LEAD the site filed (AGL-3458).\n *\n * A lead-routed form files a lead and no contact (AGL-3232), and announces it\n * with the `lead` event. An automation welcoming that person — assign an\n * owner, book a call, tag them — has only the lead to act on, so each step\n * that has a lead's equivalent writes it there: the lead's own `ownerUid`,\n * its own `tags`, a task or an activity filed under `leadId`. A stage is a\n * contact's alone, and a step setting one on a lead says so.\n */\ntype ResolvedPerson =\n | ({ kind: 'contact' } & ResolvedContact)\n | ({ kind: 'lead' } & ResolvedContact)\n\n/**\n * The contact an event names, as this site may see it.\n *\n * Id first, because a CRM event carries the document the door just wrote and\n * a lookup by id is a read, not a query. The address second, through the\n * org's address index narrowed to this site (AGL-2633), for every event that\n * predates the CRM and knows only who filled in the form — so an address a\n * merge folded into another record still reaches the person who now holds it.\n */\nasync function resolveEventContact(\n hostId: string,\n named: { contactId?: unknown; email?: unknown },\n): Promise<ResolvedContact | null> {\n const { ref: contactsRef } = await orgDataQueryForHost(hostId, 'contacts')\n const contactId = String(named.contactId ?? '').trim()\n if (contactId) {\n const doc = await contactsRef.doc(contactId).get()\n if (doc.exists && visibleToHost(doc.get('visibleTo'), hostId)) {\n return { id: doc.id, ref: doc.ref, data: doc.data() ?? {} }\n }\n }\n const hit = await findContactByEmail(contactsRef, named.email, { hostId })\n return hit ? { id: hit.id, ref: hit.ref, data: hit.data() ?? {} } : null\n}\n\n/**\n * The lead an event names, as this site may see it: by `leadId` — the `lead`\n * event carries it, and it is the person's key — else by the key of the\n * `email`. A row this site cannot see is absent, exactly as a contact's is.\n */\nasync function resolveEventLead(\n hostId: string,\n named: { leadId?: unknown; email?: unknown },\n): Promise<ResolvedContact | null> {\n const key = String(named.leadId ?? '').trim() || personKey(named.email) || ''\n if (!key) return null\n const { ref: leadsRef } = await orgDataQueryForHost(hostId, 'leads')\n const doc = await leadsRef.doc(key).get()\n if (!doc.exists || !visibleToHost(doc.get('visibleTo'), hostId)) return null\n return { id: doc.id, ref: doc.ref, data: doc.data() ?? {} }\n}\n\n/** The contact the event names, else its lead — see {@link ResolvedPerson}. */\nasync function resolveEventPerson(\n hostId: string,\n named: { contactId?: unknown; leadId?: unknown; email?: unknown },\n): Promise<ResolvedPerson | null> {\n const contact = await resolveEventContact(hostId, named)\n if (contact) return { kind: 'contact', ...contact }\n const lead = await resolveEventLead(hostId, named)\n return lead ? { kind: 'lead', ...lead } : null\n}\n\n/**\n * The uid a step names for somebody on the team — an owner, an assignee —\n * resolved against the org's roster, whichever way the step named them.\n *\n * A step names a member by uid when a picker wrote it and by address when a\n * person typed it, and the editor's one text field accepts either — so both\n * fields are read through `parseCrmMemberRef`, and an address typed into the\n * uid slot or a uid typed into the address slot still names the person.\n *\n * Both are RESOLVED, not trusted. `orgs/{orgId}/members/{uid}` is keyed by the\n * uid, so a uid costs one document read and must exist: `ownerUid` and\n * `assigneeUid` are fields every reader resolves as a member, and a stranger's\n * uid in one is a task nobody on the team can find. An address is matched on\n * the roster's own `email` field, and two production paths create a member\n * document WITHOUT one (a host-access re-grant, and an add whose auth record\n * carried none) — such a member is named by uid, which is the reason the\n * editor takes one.\n *\n * The roster is the only directory consulted. A project-level Auth lookup by\n * address would resolve people who are not on this organization at all\n * (AGL-1122). A reference that resolves neither way is an error, not a stored\n * string.\n */\nasync function resolveMemberUid(\n orgId: string | null,\n named: { uid?: string; email?: string },\n role: 'owner' | 'assignee',\n): Promise<{ uid: string; detail: string } | { error: string }> {\n const ref = parseCrmMemberRef(named.uid) ?? parseCrmMemberRef(named.email)\n if (!ref) return { error: `no ${role} named on the step` }\n if (!orgId) return { error: 'this site has no organization' }\n const members = firebaseAdmin\n .app()\n .firestore()\n .collection('orgs')\n .doc(orgId)\n .collection('members')\n if (ref.kind === 'uid') {\n const member = await members.doc(ref.uid).get()\n if (member.exists) return { uid: ref.uid, detail: ref.uid }\n return { error: `no team member with the id ${ref.uid}` }\n }\n const byField = (await members.where('email', '==', ref.email).limit(1).get()).docs[0]\n if (byField) return { uid: byField.id, detail: ref.email }\n return { error: `no team member with the address ${ref.email}` }\n}\n\n/**\n * Runs one CRM step for the person the event names: the executor the CRM\n * registers on the server-step seam for every type in `CRM_STEP_TYPES`.\n *\n * Never throws for a plan without the CRM, a missing person, an unknown owner\n * or a site with no org — those are answered as `error` so the run records\n * them; a Firestore failure propagates, and the engine records it the same\n * way.\n */\nexport async function runCrmAutomationStep(\n request: ServerStepRequest,\n nowMs = Date.now(),\n): Promise<ServerStepAnswer> {\n if (!checkEntitlement(request.org as Parameters<typeof checkEntitlement>[0], CRM_SUITE_FEATURE)) {\n return { error: `CRM steps require the ${planLabelGrantingFeature(CRM_SUITE_FEATURE)} plan` }\n }\n const env: CrmStepEnv = { hostId: request.hostId, org: request.org, orgId: request.orgId }\n const step = request.step as unknown as CrmAutomationStep\n const actionId = request.run.id\n const { hostId } = env\n const person = await resolveEventPerson(hostId, request.payload)\n if (!person) {\n const named = String(request.payload['contactId'] ?? request.payload['email'] ?? '').trim()\n return {\n error: named\n ? `no contact or lead this site can see for ${named}`\n : 'the event names no contact — no contactId or email in its payload',\n }\n }\n if (person.kind === 'lead') return runLeadStep(env, actionId, step, person, nowMs)\n const contact = person\n /*\n * THE HOLDER whose facet the write addresses — the site's consent group,\n * which is the site alone unless the org declared the site one of a set\n * that presents as one sender. Same resolution the capture doors use, so a\n * stage set here lands in the facet the console reads.\n */\n const group = await consentGroupForSite(hostId)\n const facet = readContactFacet(contact.data, group.groupId)\n const email = String(contact.data['email'] ?? '')\n\n if (step.type === 'setContactStage') {\n const previous = facet.lifecycleStage ?? ''\n const label = CONTACT_LIFECYCLE_STAGE_LABELS[step.lifecycleStage]\n /*\n * A stage set to what it already is changes nothing and ANNOUNCES\n * nothing. The write is skipped so the row's `updatedAt` does not move for\n * a non-event, and the absent emit is what stops an action that sets the\n * stage it listens for from re-running on its own write — the engine's\n * nesting cap bounds that chain, this ends it at once.\n */\n if (previous === step.lifecycleStage) {\n return { detail: `${label} (already)` }\n }\n await contact.ref.update({\n [contactFacetPath(group.groupId, 'lifecycleStage')]: step.lifecycleStage,\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The stage is what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contact.ref, 'contacts')\n /*\n * A stage set by an automation IS a stage change, and the action\n * listening for it must run — but through the engine's nesting cap, so\n * the event is answered back for the engine to raise, never raised here.\n */\n return {\n detail: label,\n emit: {\n event: 'contactStageChanged',\n payload: {\n contactId: contact.id,\n email,\n lifecycleStage: step.lifecycleStage,\n previousStage: previous,\n },\n },\n }\n }\n\n if (step.type === 'addContactTag') {\n const tag = String(step.tag ?? '')\n .trim()\n .slice(0, CONTACT_TAG_MAX_LENGTH)\n if (!tag) return { error: 'the step has no tag' }\n // `arrayUnion`, so the tag a merchant already applied by hand is not\n // duplicated and the tags beside it are kept.\n await contact.ref.update({\n [contactFacetPath(group.groupId, 'tags')]: FieldValue.arrayUnion(tag),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // The tags are what the Contacts list filters by (AGL-3321).\n await restampCrmListFieldsAt(contact.ref, 'contacts')\n return { detail: tag }\n }\n\n if (step.type === 'assignContactOwner') {\n /*\n * Through the one server assignment (AGL-2618), in both of the step's\n * modes, rather than a facet write of its own: the helper is what moves\n * the round-robin pointer inside the transaction that writes the owner,\n * mirrors the owner onto the site's lead, and tells the new owner. A\n * member named on the step is still resolved here — by address against\n * the roster, as before — and handed over by uid; the helper checks the\n * roster document again, which is one read and the same answer.\n */\n let assign: { memberUid: string } | { roundRobin: true }\n let named = 'round robin'\n if (step.roundRobin === true) {\n assign = { roundRobin: true }\n } else {\n const owner = await resolveMemberUid(\n env.orgId,\n { uid: step.ownerUid, email: step.ownerEmail },\n 'owner',\n )\n if ('error' in owner) return { error: owner.error }\n assign = { memberUid: owner.uid }\n named = owner.detail\n }\n const verdict = await reassignContactOwner({\n hostId,\n contactId: contact.id,\n email,\n assign,\n })\n if (verdict.outcome === 'none') {\n return { error: OWNER_ASSIGNMENT_REFUSALS[verdict.reason] }\n }\n if (verdict.outcome === 'unchanged') {\n return { detail: `${named} (already)` }\n }\n return {\n detail: step.roundRobin === true ? `round robin → ${verdict.ownerUid}` : named,\n }\n }\n\n // The two record creators: a task and an activity are documents of their\n // own beside the contact, stamped with the scope a contact captured on this\n // site would carry — `crmScopeTokens` is the create path's own expression,\n // so a record an automation made is visible to exactly the sites a record a\n // person made would be.\n if (!env.orgId) return { error: 'this site has no organization' }\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(env.orgId)\n const visibleTo = crmScopeTokens((env.org ?? null) as Record<string, unknown> | null, group)\n const links = {\n contactId: contact.id,\n ...(facet.companyId ? { companyId: facet.companyId } : {}),\n }\n\n if (step.type === 'createCrmTask') {\n const title = String(step.title ?? '')\n .trim()\n .slice(0, 200)\n if (!title) return { error: 'the task has no title' }\n const dueInDays = Math.max(0, Math.round(Number(step.dueInDays) || 0))\n /*\n * The assignee the step names, else the person who OWNS the contact, else\n * nobody. A follow-up task belongs to whoever holds the relationship, and\n * an automation that had to name one person for every contact it touches\n * would assign the whole list to one rep. A named assignee who cannot be\n * resolved is an error rather than a fallback to the owner: the author\n * named somebody on purpose.\n */\n let assignee = facet.ownerUid ?? ''\n if (step.assigneeUid?.trim() || step.assigneeEmail?.trim()) {\n const named = await resolveMemberUid(\n env.orgId,\n { uid: step.assigneeUid, email: step.assigneeEmail },\n 'assignee',\n )\n if ('error' in named) return { error: named.error }\n assignee = named.uid\n }\n const dueAtMs = nowMs + dueInDays * DAY_MS\n const priority: CrmTaskPriority =\n step.priority === 'high' || step.priority === 'low' ? step.priority : 'normal'\n // The org's labels for the meanings the step names (AGL-3517).\n const labels = crmTaskLabelsForNew(\n await readCrmTaskPicklists(firebaseAdmin.app().firestore(), env.orgId),\n { kind: step.kind, priority },\n )\n const task: CrmTask = {\n title,\n kind: step.kind,\n priority,\n status: 'open',\n ...labels,\n dueAtMs,\n // The reminder a person's task gets (AGL-2659): the due time.\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs, previous: null }),\n ...(assignee ? { assigneeUid: assignee } : {}),\n createdByUid: '',\n sourceActionId: actionId,\n ...links,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n // What the Tasks list searches by (AGL-3321), beside the task.\n await orgRef.collection(CRM_COLLECTIONS.tasks).add({ ...task, ...crmTaskListFields(task) })\n // The contact and company the task names carry `nextTaskAtMs` (AGL-2661);\n // a figure that could not move is the Fields section's recompute's.\n await recomputeCrmNextTaskAt(firebaseAdmin.app().firestore(), env.orgId, [links]).catch(\n (error: unknown) => {\n console.error('[crm] next activity could not be recomputed', env.orgId, error)\n },\n )\n return { detail: title.slice(0, 60) }\n }\n\n if (step.type === 'logCrmActivity') {\n const body = String(step.body ?? '')\n .trim()\n .slice(0, 2000)\n if (!body) return { error: 'the activity has no body' }\n /*\n * The per-record ceiling (AGL-2611), and this step is the writer it exists\n * for: a flow that logs on every page view fills one person's log in an\n * afternoon. Refused into the run history as an error, the way an\n * unresolvable assignee is, so the merchant reads why the flow stopped\n * writing rather than finding a log that silently stopped.\n */\n const logged = await countCrmActivitiesForRecord(orgRef, links)\n if (!crmActivityLogHasRoom(logged)) {\n return { error: CRM_ACTIVITY_LOG_FULL_MESSAGE }\n }\n const direction = crmActivityDirection(step.kind, step.direction)\n const activity: CrmActivity = {\n kind: step.kind,\n body,\n ...(direction ? { direction } : {}),\n atMs: nowMs,\n byUid: '',\n sourceActionId: actionId,\n ...links,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n await orgRef.collection(CRM_COLLECTIONS.activities).add(activity)\n return { detail: step.kind }\n }\n\n return { error: `unknown CRM step \"${(step as { type: string }).type}\"` }\n}\n\n/**\n * A CRM step on a LEAD (AGL-3458) — the person is not a contact yet, so each\n * step writes the lead's equivalent of what it writes on a contact. See\n * {@link ResolvedPerson}.\n */\nasync function runLeadStep(\n env: CrmStepEnv,\n actionId: string,\n step: CrmAutomationStep,\n lead: ResolvedContact,\n nowMs: number,\n): Promise<ServerStepAnswer> {\n const { hostId } = env\n if (step.type === 'setContactStage') {\n return {\n error:\n 'the event names a lead, and a lead has no lifecycle stage until it is ' +\n 'converted to a contact',\n }\n }\n if (step.type === 'addContactTag') {\n const tag = String(step.tag ?? '')\n .trim()\n .slice(0, CONTACT_TAG_MAX_LENGTH)\n if (!tag) return { error: 'the step has no tag' }\n await lead.ref.update({\n tags: FieldValue.arrayUnion(tag),\n updatedAt: FieldValue.serverTimestamp(),\n })\n // A lead's tags are what the Leads list searches (AGL-3321).\n await restampCrmListFieldsAt(lead.ref, 'leads')\n return { detail: `${tag} (lead)` }\n }\n if (step.type === 'assignContactOwner') {\n let assign: { memberUid: string } | { roundRobin: true }\n let named = 'round robin'\n if (step.roundRobin === true) {\n assign = { roundRobin: true }\n } else {\n const owner = await resolveMemberUid(\n env.orgId,\n { uid: step.ownerUid, email: step.ownerEmail },\n 'owner',\n )\n if ('error' in owner) return { error: owner.error }\n assign = { memberUid: owner.uid }\n named = owner.detail\n }\n const verdict = await reassignLeadOwner({ hostId, leadId: lead.id, assign })\n if (verdict.outcome === 'none') {\n return { error: OWNER_ASSIGNMENT_REFUSALS[verdict.reason] }\n }\n if (verdict.outcome === 'unchanged') return { detail: `${named} (already, lead)` }\n return {\n detail:\n step.roundRobin === true\n ? `round robin → ${verdict.ownerUid} (lead)`\n : `${named} (lead)`,\n }\n }\n if (!env.orgId) return { error: 'this site has no organization' }\n const orgRef = firebaseAdmin.app().firestore().collection('orgs').doc(env.orgId)\n const group = await consentGroupForSite(hostId)\n const visibleTo = crmScopeTokens((env.org ?? null) as Record<string, unknown> | null, group)\n // Filed under the lead, as a sequence's call step files one (AGL-3233); a\n // conversion stamps the contact beside it, and the task follows the person.\n const links = { leadId: lead.id }\n if (step.type === 'createCrmTask') {\n const title = String(step.title ?? '')\n .trim()\n .slice(0, 200)\n if (!title) return { error: 'the task has no title' }\n const dueInDays = Math.max(0, Math.round(Number(step.dueInDays) || 0))\n // The named assignee, else whoever owns the lead NOW — an owner step\n // earlier in this same run wrote the lead after it was resolved, so it\n // is read again — else nobody.\n let assignee: string\n if (step.assigneeUid?.trim() || step.assigneeEmail?.trim()) {\n const named = await resolveMemberUid(\n env.orgId,\n { uid: step.assigneeUid, email: step.assigneeEmail },\n 'assignee',\n )\n if ('error' in named) return { error: named.error }\n assignee = named.uid\n } else {\n const fresh = await lead.ref.get()\n assignee = String((fresh.data() ?? lead.data)['ownerUid'] ?? '')\n }\n const dueAtMs = nowMs + dueInDays * DAY_MS\n const task: CrmTask = {\n title,\n kind: step.kind,\n priority: 'normal',\n status: 'open',\n dueAtMs,\n remindAtMs: crmTaskReminderAfterEdit({ dueAtMs, previous: null }),\n ...(assignee ? { assigneeUid: assignee } : {}),\n createdByUid: '',\n sourceActionId: actionId,\n ...links,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n await orgRef\n .collection(CRM_COLLECTIONS.tasks)\n .doc(createResourceUid())\n .set({ ...task, ...crmTaskListFields(task) })\n return { detail: `${title.slice(0, 60)} (lead)` }\n }\n if (step.type === 'logCrmActivity') {\n const body = String(step.body ?? '')\n .trim()\n .slice(0, 2000)\n if (!body) return { error: 'the activity has no body' }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, links))) {\n return { error: CRM_ACTIVITY_LOG_FULL_MESSAGE }\n }\n const activity: CrmActivity = {\n kind: step.kind,\n body,\n atMs: nowMs,\n byUid: '',\n sourceActionId: actionId,\n ...links,\n hostId,\n visibleTo,\n createdAt: FieldValue.serverTimestamp(),\n updatedAt: FieldValue.serverTimestamp(),\n }\n await orgRef.collection(CRM_COLLECTIONS.activities).doc(createResourceUid()).set(activity)\n return { detail: `${step.kind} (lead)` }\n }\n return { error: `unknown CRM step \"${(step as { type: string }).type}\"` }\n}\n\n/**\n * Whether — and where — an email a plugin is about to send lands on a\n * timeline: the CRM's answer to the record timeline's `prepareEmail`.\n *\n * An automation's `sendEmail` step is not a CRM step: it mails whatever\n * address the event carries, to a person who may be nobody the CRM knows. It\n * earns a row on exactly one condition, that the message is ADDRESSED TO THE\n * PERSON the sender means — a contact, else the lead a lead-routed form filed\n * (AGL-3458), whose row lands on the lead's timeline — the person the request's link finds, at the\n * address the row holds. A welcome sequence to a new contact is that; an\n * internal alert routed to a merchant's own address through `toField` is not,\n * and a row for it would put the merchant's inbox on a customer's history.\n *\n * Prepared ahead of the send, for the reason the console route mints its id\n * first: the delivery webhook has nothing but the tags on the message to find\n * the row with. Nothing is written until `file` is called, once the provider\n * accepted the message. A workspace whose plan does not carry the CRM, a\n * record at the activity ceiling and a site with no org earn no row — the\n * message still goes, because the ceiling bounds the log and not the mail.\n *\n * **Never throws**, and neither does `file`. The row is bookkeeping beside a\n * send, and a lookup that fails must not become a message that never left —\n * the posture every meter beside a send takes. A failure is logged and the\n * message goes out untagged.\n */\nexport async function prepareCrmRecordEmail(\n request: PluginRecordEmailPrepareRequest,\n): Promise<PluginRecordPreparedEmail | null> {\n const orgId = String(request.orgId ?? '').trim()\n const hostId = String(request.hostId ?? '').trim()\n if (!orgId || !hostId) return null\n const address = normalizeContactEmail(request.to)\n if (!address) return null\n try {\n const firestore = firebaseAdmin.app().firestore()\n const orgRef = firestore.collection('orgs').doc(orgId)\n // The org the caller already read, or the one read here once.\n const org =\n request.org !== undefined ? request.org : ((await orgRef.get()).data() ?? null)\n if (!checkEntitlement(org as Parameters<typeof checkEntitlement>[0], CRM_SUITE_FEATURE)) {\n return null\n }\n const person = await resolveEventPerson(hostId, request.link)\n if (!person || normalizeContactEmail(person.data['email']) !== address) {\n return null\n }\n const group = await consentGroupForSite(hostId)\n const facet = person.kind === 'contact' ? readContactFacet(person.data, group.groupId) : null\n const link: CrmActivityLink =\n person.kind === 'lead'\n ? { leadId: person.id }\n : {\n contactId: person.id,\n ...(facet?.companyId ? { companyId: facet.companyId } : {}),\n }\n if (!crmActivityLogHasRoom(await countCrmActivitiesForRecord(orgRef, link))) {\n return null\n }\n const ref = newCrmActivityRef(firestore, orgId)\n const visibleTo = crmScopeTokens((org ?? null) as Record<string, unknown> | null, group)\n return {\n tags: crmEmailDeliveryTags({ orgId, hostId, activityId: ref.id }),\n /*\n * The same shape the console route logs — `buildCrmEmailActivity` is the\n * one builder — with the sender's id as its source and nobody as its\n * author. Never throws: the message has left, and a row that could not\n * be written is a gap on the timeline rather than a failed send.\n */\n async file(sent) {\n try {\n await writeCrmEmailActivity(\n ref,\n buildCrmEmailActivity({\n subject: sent.subject,\n body: sent.body,\n to: normalizeContactEmail(sent.to) ?? sent.to,\n atMs: sent.atMs ?? Date.now(),\n byUid: '',\n sourceActionId: sent.sourceRef,\n link,\n hostId,\n visibleTo,\n }),\n )\n } catch (error) {\n console.error('[crm] automation email activity write failed', ref.id, error)\n }\n },\n }\n } catch (error) {\n console.error('[crm] automation email activity could not be prepared', hostId, error)\n return null\n }\n}\n"],"names":["buildCrmEmailActivity","checkEntitlement","CONTACT_LIFECYCLE_STAGE_LABELS","CONTACT_TAG_MAX_LENGTH","contactFacetPath","CRM_ACTIVITY_LOG_FULL_MESSAGE","CRM_COLLECTIONS","crmActivityLogHasRoom","crmActivityDirection","crmEmailDeliveryTags","crmScopeTokens","crmTaskLabelsForNew","crmTaskListFields","normalizeContactEmail","parseCrmMemberRef","personKey","planLabelGrantingFeature","readContactFacet","visibleToHost","consentGroupForSite","countCrmActivitiesForRecord","firebaseAdmin","orgDataQueryForHost","restampCrmListFieldsAt","findContactByEmail","FieldValue","OWNER_ASSIGNMENT_REFUSALS","reassignContactOwner","reassignLeadOwner","createResourceUid","readCrmTaskPicklists","CRM_SUITE_FEATURE","crmTaskReminderAfterEdit","recomputeCrmNextTaskAt","newCrmActivityRef","writeCrmEmailActivity","DAY_MS","resolveEventContact","hostId","named","hit","ref","contactsRef","contactId","String","trim","doc","get","exists","id","data","email","resolveEventLead","key","leadId","leadsRef","resolveEventPerson","contact","kind","lead","resolveMemberUid","orgId","role","uid","error","members","app","firestore","collection","member","detail","byField","where","limit","docs","runCrmAutomationStep","request","nowMs","Date","now","env","org","step","actionId","run","person","payload","runLeadStep","group","facet","groupId","type","previous","lifecycleStage","label","update","updatedAt","serverTimestamp","emit","event","previousStage","tag","slice","arrayUnion","assign","roundRobin","owner","ownerUid","ownerEmail","memberUid","verdict","outcome","reason","orgRef","visibleTo","links","companyId","title","dueInDays","Math","max","round","Number","assignee","assigneeUid","assigneeEmail","dueAtMs","priority","labels","task","status","remindAtMs","createdByUid","sourceActionId","createdAt","tasks","add","catch","console","body","logged","direction","activity","atMs","byUid","activities","tags","fresh","set","prepareCrmRecordEmail","address","to","undefined","link","activityId","file","sent","subject","sourceRef"],"mappings":"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"}
|
|
@@ -67,6 +67,23 @@ import { type ContactSource } from '@aglyn/aglyn/app-utils/contacts';
|
|
|
67
67
|
* way a repeat visit by a contact is an interaction and not a creation.
|
|
68
68
|
*/
|
|
69
69
|
export declare function captureContactForCrm(request: PluginContactCaptureRequest): Promise<PluginContactCaptured>;
|
|
70
|
+
/**
|
|
71
|
+
* The `lead` payload (AGL-3458), as the scalars an event may carry — the
|
|
72
|
+
* shape `contactCreatedPayload` gives a new contact, so one condition reads
|
|
73
|
+
* the same on either event: `name` always present, empty when the door had
|
|
74
|
+
* none; `formId` only when a form filed the lead, so `notEmpty` on it reads
|
|
75
|
+
* as "came in through a form" and `equals` picks one form's people out; and
|
|
76
|
+
* `campaignIds` comma-joined, only when the surface is filed under any.
|
|
77
|
+
*/
|
|
78
|
+
export declare function leadCreatedPayload(lead: {
|
|
79
|
+
leadId: string;
|
|
80
|
+
email: string;
|
|
81
|
+
name?: string | null;
|
|
82
|
+
source: string;
|
|
83
|
+
hostId: string;
|
|
84
|
+
formId?: string | null;
|
|
85
|
+
campaignIds?: readonly string[];
|
|
86
|
+
}): Record<string, string>;
|
|
70
87
|
/**
|
|
71
88
|
* The silo's word for its door, as a source the CRM stores.
|
|
72
89
|
*
|
|
@@ -183,7 +183,7 @@ import { crmRecordOriginWriter } from "./record-origin.js";
|
|
|
183
183
|
source
|
|
184
184
|
}, request.marketingConsent ? {
|
|
185
185
|
marketingConsent: true
|
|
186
|
-
} : {}, disclosureOf(request), phoneFillOf(request))
|
|
186
|
+
} : {}, disclosureOf(request), campaignsFiledUnder(request), phoneFillOf(request))
|
|
187
187
|
}, conversionTouchOf(request.detail).conversionTouch ? {
|
|
188
188
|
touch: conversionTouchOf(request.detail).conversionTouch
|
|
189
189
|
} : {}));
|
|
@@ -196,13 +196,15 @@ import { crmRecordOriginWriter } from "./record-origin.js";
|
|
|
196
196
|
}
|
|
197
197
|
if (created) {
|
|
198
198
|
const cause = contactCaptureActor(request.interaction.source, email, request.actor);
|
|
199
|
-
await emitHostEvent(request.hostId, 'lead',
|
|
199
|
+
await emitHostEvent(request.hostId, 'lead', leadCreatedPayload({
|
|
200
|
+
leadId: key,
|
|
200
201
|
email,
|
|
202
|
+
name: request.identity.name,
|
|
201
203
|
source,
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
204
|
+
hostId: request.hostId,
|
|
205
|
+
formId,
|
|
206
|
+
campaignIds: campaignsFiledUnder(request).campaignIds
|
|
207
|
+
}), cause ? {
|
|
206
208
|
actor: cause
|
|
207
209
|
} : {});
|
|
208
210
|
}
|
|
@@ -214,6 +216,29 @@ import { crmRecordOriginWriter } from "./record-origin.js";
|
|
|
214
216
|
sourceAdded: outcome.sourceAdded
|
|
215
217
|
};
|
|
216
218
|
}
|
|
219
|
+
/**
|
|
220
|
+
* The `lead` payload (AGL-3458), as the scalars an event may carry — the
|
|
221
|
+
* shape `contactCreatedPayload` gives a new contact, so one condition reads
|
|
222
|
+
* the same on either event: `name` always present, empty when the door had
|
|
223
|
+
* none; `formId` only when a form filed the lead, so `notEmpty` on it reads
|
|
224
|
+
* as "came in through a form" and `equals` picks one form's people out; and
|
|
225
|
+
* `campaignIds` comma-joined, only when the surface is filed under any.
|
|
226
|
+
*/ export function leadCreatedPayload(lead) {
|
|
227
|
+
var _lead_formId, _lead_name;
|
|
228
|
+
var _lead_campaignIds;
|
|
229
|
+
const formId = String((_lead_formId = lead.formId) != null ? _lead_formId : '').trim();
|
|
230
|
+
return _extends({
|
|
231
|
+
leadId: lead.leadId,
|
|
232
|
+
email: lead.email,
|
|
233
|
+
name: String((_lead_name = lead.name) != null ? _lead_name : '').trim(),
|
|
234
|
+
source: lead.source,
|
|
235
|
+
hostId: lead.hostId
|
|
236
|
+
}, formId ? {
|
|
237
|
+
formId
|
|
238
|
+
} : {}, ((_lead_campaignIds = lead.campaignIds) == null ? void 0 : _lead_campaignIds.length) ? {
|
|
239
|
+
campaignIds: lead.campaignIds.join(',')
|
|
240
|
+
} : {});
|
|
241
|
+
}
|
|
217
242
|
/** The capture as a CONTACT — what every capture was before the rule above. */ async function captureOnContact(request) {
|
|
218
243
|
try {
|
|
219
244
|
var _request_tags;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport { normalizePhone } from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from './capture-host-contact'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport {\n CONVERSION_TOUCH_DETAIL,\n type PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\nimport { crmRecordOriginWriter } from './record-origin'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await stampedAfter(request, fileLead(request))\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await stampedAfter(request, fileLead(request))\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok) await stampOrigin(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n // Money changing hands is a purchase whichever door took it — a paid\n // booking or a stay is one as much as an order is.\n by:\n request.interaction.source === 'order' || (request.purchaseCents ?? 0) > 0\n ? 'purchase'\n : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * WHERE THE PERSON CAME FROM (AGL-3519): the door's word — a form, a\n * booking, a newsletter, a member, an order — as the built-in Lead source\n * it names, on a record the capture just started and that names none. A\n * person met before keeps whatever lead source they hold, or the lack of one.\n * Never fails the capture: the writer swallows its own errors.\n */\nasync function stampOrigin(request: PluginContactCaptureRequest): Promise<void> {\n await crmRecordOriginWriter.stamp({\n hostId: request.hostId,\n email: normalizeContactEmail(request.identity.email) ?? '',\n origin: request.interaction.source,\n firstTouchOnly: true,\n })\n}\n\n/** A filed lead's verdict, after its origin is stamped when the filing took. */\nasync function stampedAfter(\n request: PluginContactCaptureRequest,\n filed: Promise<PluginContactCaptured>,\n): Promise<PluginContactCaptured> {\n const verdict = await filed\n if (verdict.ok) await stampOrigin(request)\n return verdict\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n ...phoneFillOf(request),\n },\n ...(conversionTouchOf(request.detail).conversionTouch\n ? { touch: conversionTouchOf(request.detail).conversionTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n const cause = contactCaptureActor(\n request.interaction.source,\n email,\n request.actor,\n )\n await emitHostEvent(\n request.hostId,\n 'lead',\n {\n email,\n source,\n leadId: key,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n },\n cause ? { actor: cause } : {},\n )\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n ...(request.actor ? { actor: request.actor } : {}),\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...conversionTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...campaignsFiledUnder(request),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n ...(phoneFillOf(request).phoneFill\n ? { facetFill: { phone: phoneFillOf(request).phoneFill } }\n : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE PHONE A CAPTURE LEARNED IN PASSING (AGL-3493), off `profileFill`.\n *\n * Normalized here so a lead and a contact written by one capture keep the\n * same E.164 string, and dropped when it will not normalize: a fill is an\n * enrichment, and a number in some other shape on the record is the\n * \"stored three ways\" failure `normalizePhone` exists to end. Both writers\n * put it only where the record holds no phone.\n */\nfunction phoneFillOf(request: PluginContactCaptureRequest): { phoneFill?: string } {\n const raw = request.profileFill?.['phone']\n const phone = typeof raw === 'string' ? normalizePhone(raw) : null\n return phone ? { phoneFill: phone } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * The campaigns the capture surface is filed under, as the contact writer\n * records them. A capture names what its surface is filed under by container\n * kind; the CRM keeps the `campaign` kind on the person's site facet, and a\n * kind it does not keep is the capture's to name and the CRM's to ignore.\n */\nfunction campaignsFiledUnder(\n request: PluginContactCaptureRequest,\n): Pick<UpsertHostContactOptions, 'campaignIds'> {\n const ids = request.containers?.['campaign'] ?? []\n return ids.length ? { campaignIds: ids } : {}\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved by the plugin that\n * credits outcomes (`plugin-conversion-credit`) at the silo's door;\n * `campaignIds` is which campaigns the merchant filed the capture SURFACE\n * under, which is true of everybody who fills that form in. Folding them\n * would credit a campaign for a visitor who typed the address.\n *\n * Passed through as the silo handed it, unread: the touch's shape belongs\n * to the creditor that resolved it, and the creditor checks it again when\n * the contact or the lead is credited. Only its presence is decided here,\n * because `null` and absent mean the same thing to the writer and a caller\n * should not have to know which one it sends.\n */\nfunction conversionTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'conversionTouch'> {\n const touch = detail?.[CONVERSION_TOUCH_DETAIL]\n return touch && typeof touch === 'object'\n ? { conversionTouch: touch as PluginConversionTouch }\n : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","normalizePhone","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","contactCaptureActor","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","CONVERSION_TOUCH_DETAIL","convertOpenLeadOntoContact","crmRecordOriginWriter","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","stampedAfter","fileLead","openLeadFor","verdict","captureOnContact","ok","stampOrigin","record","hostId","contactId","by","interaction","source","purchaseCents","error","console","reason","stamp","origin","firstTouchOnly","filed","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","phoneFillOf","conversionTouchOf","conversionTouch","touch","stored","cause","actor","leadId","sourceAdded","contactSourceOf","atMs","undefined","refId","summary","tags","length","campaignsFiledUnder","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","phoneFill","facetFill","phone","refused","disclosedConsentGroup","raw","profileFill","text","value","trim","path","ids","containers","campaignIds","word","String","Object","hasOwn","warn"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/crm/src/lib/server/capture-contact.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type {\n PluginContactCaptureRequest,\n PluginContactCaptured,\n} from '@aglyn/aglyn/plugin-manager/plugin-contact-capture'\nimport {\n CONTACT_SOURCE_LABELS,\n type ContactSource,\n} from '@aglyn/aglyn/app-utils/contacts'\nimport { formLeadSource } from '@aglyn/aglyn/app-utils/forms'\nimport { normalizePhone } from '@aglyn/aglyn/foundation/definitions/contact.types'\nimport {\n type CrmLeadFields,\n isCrmLeadOpen,\n normalizeContactEmail,\n personKey,\n} from '@aglyn/aglyn/server'\nimport { captureHostContact } from './capture-host-contact'\nimport { contactCaptureActor } from '@aglyn/tenant-runtime/capture-actor'\nimport { emitHostEvent } from '@aglyn/tenant-runtime/emit-host-event'\nimport {\n addHostLeadOutcome,\n findContactByEmail,\n firebaseAdmin,\n orgDataCollectionForHost,\n readLeadForHost,\n type UpsertHostContactOptions,\n} from '@aglyn/tenant-data-admin'\nimport {\n CONVERSION_TOUCH_DETAIL,\n type PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { convertOpenLeadOntoContact } from './convert-open-lead'\nimport { crmRecordOriginWriter } from './record-origin'\n\n/**\n * The CRM answering the platform's contact-capture contract (AGL-3080).\n *\n * Four silos meet the same person — a form submission, a member signing up,\n * an order, a booking — and none of them is the record system. Each holds an\n * address, a name and the fact that something happened, and each wants the\n * workspace's person record to know. Today each imports `captureHostContact`\n * directly, which is the highest fan-in edge in the repo and makes the CRM\n * something a storefront cannot take a payment without.\n *\n * So this is the CRM's side of the seam: the silo reports what it saw, and\n * the plugin that keeps people decides everything a record system decides —\n * keying the address, whether this is somebody new, the audience band, the\n * erasure rows, the company, the owner, and what a new person sets off.\n * `captureHostContact` already does all of that; this translates the\n * contract's vocabulary into its options and its verdict back.\n *\n * ⚠️ IT NEVER THROWS. Every caller has already done the thing it is\n * recording — the submission was accepted, the order was paid — so a refusal\n * is RETURNED and costs the silo nothing. A throw here would lose an order\n * for a contact that could not be filed.\n */\n/**\n * WHICH RECORD A CAPTURE LANDS ON (AGL-3232) — the Salesforce rule, decided\n * here because the CRM is the plugin that models both records.\n *\n * One person is one record: a LEAD until somebody qualifies them, a CONTACT\n * after. Every door used to write both — a lead-routed form filed a lead\n * AND a contact at stage Lead — so one person sat in two lists and the Leads\n * queue was never the whole story. Now a door says what kind of surface it\n * is (`request.surface`) and this decides:\n *\n * - a LEAD surface files a lead, unless the workspace already holds the\n * address as a contact — a customer who books a demo is a customer's\n * interaction, not a new lead — in which case the capture lands on the\n * contact and no lead is filed;\n * - a RELATIONSHIP (a member account, a purchase) makes the contact, and\n * an open lead the site held for the address is stamped converted onto\n * it, so nobody keeps working a lead who already joined or bought;\n * - a TOUCH (an unrouted form, a newsletter opt-in) lands on the open lead\n * when the site holds one — its consent and its history stay on the one\n * record the rep is working — and on the contact otherwise.\n *\n * A lead is filed through `addHostLead`, the one writer of the leads silo,\n * so it is keyed on the person, counted against the ceiling and carries the\n * campaign touch the door resolved. A NEW lead announces itself with the\n * `lead` host event, which is what a \"new lead\" automation listens for; a\n * repeat capture on a lead the site already held announces nothing, the\n * way a repeat visit by a contact is an interaction and not a creation.\n */\nexport async function captureContactForCrm(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n const surface = request.surface ?? 'touch'\n // The one refusal every surface shares, answered before any read: an\n // address nothing can key is a person nothing can record.\n if (!normalizeContactEmail(request.identity.email)) return refusedEmail()\n try {\n if (surface === 'lead') {\n if (!(await heldAsContact(request))) return await stampedAfter(request, fileLead(request))\n } else if (surface === 'touch') {\n if (await openLeadFor(request)) return await stampedAfter(request, fileLead(request))\n }\n const verdict = await captureOnContact(request)\n if (verdict.ok) await stampOrigin(request)\n if (verdict.ok && verdict.record === 'contact' && surface === 'relationship') {\n await convertOpenLeadOntoContact({\n hostId: request.hostId,\n email: request.identity.email,\n contactId: verdict.contactId,\n // Money changing hands is a purchase whichever door took it — a paid\n // booking or a stay is one as much as an order is.\n by:\n request.interaction.source === 'order' || (request.purchaseCents ?? 0) > 0\n ? 'purchase'\n : 'signup',\n })\n }\n return verdict\n } catch (error) {\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * WHERE THE PERSON CAME FROM (AGL-3519): the door's word — a form, a\n * booking, a newsletter, a member, an order — as the built-in Lead source\n * it names, on a record the capture just started and that names none. A\n * person met before keeps whatever lead source they hold, or the lack of one.\n * Never fails the capture: the writer swallows its own errors.\n */\nasync function stampOrigin(request: PluginContactCaptureRequest): Promise<void> {\n await crmRecordOriginWriter.stamp({\n hostId: request.hostId,\n email: normalizeContactEmail(request.identity.email) ?? '',\n origin: request.interaction.source,\n firstTouchOnly: true,\n })\n}\n\n/** A filed lead's verdict, after its origin is stamped when the filing took. */\nasync function stampedAfter(\n request: PluginContactCaptureRequest,\n filed: Promise<PluginContactCaptured>,\n): Promise<PluginContactCaptured> {\n const verdict = await filed\n if (verdict.ok) await stampOrigin(request)\n return verdict\n}\n\n/** The refusal every door gets for an address nothing can key. */\nfunction refusedEmail(): PluginContactCaptured {\n return { ok: false, reason: 'invalid-email', error: refusalText('invalid-email') }\n}\n\n/**\n * Whether the workspace already holds the address as a contact — the\n * address index first, then the query, the same lookup every dedupe uses.\n * ORG-wide, not scoped to the capturing site: the contact door dedupes\n * across every site in the org, so a person any site holds as a contact\n * is a contact, whichever site met them this time.\n */\nasync function heldAsContact(request: PluginContactCaptureRequest): Promise<boolean> {\n const contacts = await orgDataCollectionForHost(request.hostId, 'contacts')\n return (await findContactByEmail(contacts, request.identity.email)) !== null\n}\n\n/** Whether the site holds an OPEN lead for the address — neither converted nor closed. */\nasync function openLeadFor(request: PluginContactCaptureRequest): Promise<boolean> {\n const key = personKey(request.identity.email) as string\n /*\n * Through the seam, which reads the org row and falls back to a lead the\n * backfill has not reached (AGL-3275). UNSCOPED on purpose: a sibling brand\n * in the same consent group has to find this person, or its door concludes\n * there is no lead and files the duplicate this migration exists to end.\n */\n const snapshot = await readLeadForHost(request.hostId, key)\n if (!snapshot) return false\n const lead = (snapshot.data() ?? {}) as Record<string, unknown> & CrmLeadFields\n return !lead.convertedContactId && isCrmLeadOpen(lead)\n}\n\n/**\n * The capture as a LEAD. The source string is the lead silo's own\n * vocabulary — `form:{formId}` for a form, the door's word otherwise — so\n * a lead's provenance survives the form being renamed, as the submission's\n * does.\n */\nasync function fileLead(request: PluginContactCaptureRequest): Promise<PluginContactCaptured> {\n const email = normalizeContactEmail(request.identity.email) as string\n const key = personKey(email) as string\n const hostRef = firebaseAdmin.app().firestore().collection('hosts').doc(request.hostId)\n // `addHostLead` resolves and writes the org collection itself (AGL-3275);\n // this only needs to know whether the person is new, which decides the event.\n const created = !(await readLeadForHost(request.hostId, key))\n const { formId } = entryPointOf(request.detail)\n const source =\n request.interaction.source === 'form' && formId\n ? formLeadSource(formId)\n : request.interaction.source\n const outcome = await addHostLeadOutcome({\n hostRef,\n hostId: request.hostId,\n lead: {\n email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source,\n ...(request.marketingConsent ? { marketingConsent: true } : {}),\n ...disclosureOf(request),\n // Filed under the surface's campaigns, as the contact door files a\n // contact (AGL-3458): a lead-routed form in a campaign makes a lead\n // in that campaign.\n ...campaignsFiledUnder(request),\n ...phoneFillOf(request),\n },\n ...(conversionTouchOf(request.detail).conversionTouch\n ? { touch: conversionTouchOf(request.detail).conversionTouch }\n : {}),\n })\n if (!outcome.stored) {\n return {\n ok: false,\n reason: 'band',\n error: 'This site is at the number of leads it may hold, so the lead was not recorded.',\n }\n }\n if (created) {\n const cause = contactCaptureActor(\n request.interaction.source,\n email,\n request.actor,\n )\n await emitHostEvent(\n request.hostId,\n 'lead',\n leadCreatedPayload({\n leadId: key,\n email,\n name: request.identity.name,\n source,\n hostId: request.hostId,\n formId,\n campaignIds: campaignsFiledUnder(request).campaignIds,\n }),\n cause ? { actor: cause } : {},\n )\n }\n return { ok: true, record: 'lead', leadId: key, created, sourceAdded: outcome.sourceAdded }\n}\n\n/**\n * The `lead` payload (AGL-3458), as the scalars an event may carry — the\n * shape `contactCreatedPayload` gives a new contact, so one condition reads\n * the same on either event: `name` always present, empty when the door had\n * none; `formId` only when a form filed the lead, so `notEmpty` on it reads\n * as \"came in through a form\" and `equals` picks one form's people out; and\n * `campaignIds` comma-joined, only when the surface is filed under any.\n */\nexport function leadCreatedPayload(lead: {\n leadId: string\n email: string\n name?: string | null\n source: string\n hostId: string\n formId?: string | null\n campaignIds?: readonly string[]\n}): Record<string, string> {\n const formId = String(lead.formId ?? '').trim()\n return {\n leadId: lead.leadId,\n email: lead.email,\n name: String(lead.name ?? '').trim(),\n source: lead.source,\n hostId: lead.hostId,\n ...(formId ? { formId } : {}),\n ...(lead.campaignIds?.length ? { campaignIds: lead.campaignIds.join(',') } : {}),\n }\n}\n\n/** The capture as a CONTACT — what every capture was before the rule above. */\nasync function captureOnContact(\n request: PluginContactCaptureRequest,\n): Promise<PluginContactCaptured> {\n try {\n const verdict = await captureHostContact({\n ...(request.actor ? { actor: request.actor } : {}),\n hostId: request.hostId,\n email: request.identity.email,\n ...(request.identity.name ? { name: request.identity.name } : {}),\n source: contactSourceOf(request.interaction.source),\n interaction: {\n ...(request.interaction.atMs === undefined\n ? {}\n : { atMs: request.interaction.atMs }),\n ...(request.interaction.refId ? { refId: request.interaction.refId } : {}),\n ...(request.interaction.summary\n ? { summary: request.interaction.summary }\n : {}),\n ...entryPointOf(request.detail),\n },\n ...conversionTouchOf(request.detail),\n ...(request.marketingConsent === undefined\n ? {}\n : { marketingConsent: request.marketingConsent }),\n ...disclosureOf(request),\n ...(request.tags?.length ? { tags: request.tags } : {}),\n ...campaignsFiledUnder(request),\n ...(request.purchaseCents === undefined\n ? {}\n : { purchaseCents: request.purchaseCents }),\n ...(request.purchaseCurrency\n ? { purchaseCurrency: request.purchaseCurrency }\n : {}),\n ...(request.lifecycleFloor\n ? { initialLifecycleStage: request.lifecycleFloor as never }\n : {}),\n ...(request.profile ? { facet: request.profile as never } : {}),\n ...(phoneFillOf(request).phoneFill\n ? { facetFill: { phone: phoneFillOf(request).phoneFill } }\n : {}),\n })\n if ('refused' in verdict) {\n return { ok: false, reason: verdict.refused, error: refusalText(verdict.refused) }\n }\n return {\n ok: true,\n record: 'contact',\n contactId: verdict.contactId,\n created: verdict.created,\n }\n } catch (error) {\n /*\n * The contract's `error` is the last state, not a channel for a stack:\n * a silo may show or log it as it stands, so it says what happened and\n * names nothing internal. The detail goes to the log, where whoever is\n * debugging a missing contact will look.\n */\n console.error('crm contact capture failed', error)\n return {\n ok: false,\n reason: 'error',\n error: 'The contact could not be recorded. Nothing else was affected.',\n }\n }\n}\n\n/**\n * THE DISCLOSURE the capture surface rendered, handed to whichever record the\n * capture lands on (AGL-3320).\n *\n * Passed through unread: both writers resolve the site's group themselves and\n * pool the opt-in only when this is its current key, so a lead and a contact\n * written by one capture record the same sites. A string or nothing, since\n * the writers compare it exactly and anything else could only fail to match.\n */\nfunction disclosureOf(\n request: PluginContactCaptureRequest,\n): { disclosedConsentGroup?: string } {\n const key = request.disclosedConsentGroup\n return typeof key === 'string' && key ? { disclosedConsentGroup: key } : {}\n}\n\n/**\n * THE PHONE A CAPTURE LEARNED IN PASSING (AGL-3493), off `profileFill`.\n *\n * Normalized here so a lead and a contact written by one capture keep the\n * same E.164 string, and dropped when it will not normalize: a fill is an\n * enrichment, and a number in some other shape on the record is the\n * \"stored three ways\" failure `normalizePhone` exists to end. Both writers\n * put it only where the record holds no phone.\n */\nfunction phoneFillOf(request: PluginContactCaptureRequest): { phoneFill?: string } {\n const raw = request.profileFill?.['phone']\n const phone = typeof raw === 'string' ? normalizePhone(raw) : null\n return phone ? { phoneFill: phone } : {}\n}\n\n/**\n * THE ENTRY POINT, off the silo's own `detail` bag (AGL-3080).\n *\n * The contract carries a capture's silo-side facts opaquely, so the two the\n * CRM models are picked out here rather than typed into the platform. Both\n * ride the INTERACTION, which is where `upsertHostContact` already keeps\n * them: which form a person came in through routes the owner-assignment\n * rules and rides the `contactCreated` payload, and the page is what a\n * timeline row says about where they were.\n *\n * Absent, misspelled or the wrong type is the same answer as a door that\n * never had one — a capture without an entry point, which is every capture\n * that did not come through a form. It is never a reason to refuse: the\n * person is the part that matters.\n */\nfunction entryPointOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): { formId?: string; path?: string } {\n const text = (value: unknown): string | undefined =>\n typeof value === 'string' && value.trim() ? value : undefined\n const formId = text(detail?.['formId'])\n const path = text(detail?.['path'])\n return {\n ...(formId ? { formId } : {}),\n ...(path ? { path } : {}),\n }\n}\n\n/**\n * The campaigns the capture surface is filed under, as the contact writer\n * records them. A capture names what its surface is filed under by container\n * kind; the CRM keeps the `campaign` kind on the person's site facet, and a\n * kind it does not keep is the capture's to name and the CRM's to ignore.\n */\nfunction campaignsFiledUnder(\n request: PluginContactCaptureRequest,\n): Pick<UpsertHostContactOptions, 'campaignIds'> {\n const ids = request.containers?.['campaign'] ?? []\n return ids.length ? { campaignIds: ids } : {}\n}\n\n/**\n * WHERE THE VISITOR CAME FROM, off the same bag.\n *\n * ⚠️ A different fact from `campaignIds` and the two must never be folded\n * together — `upsert-contact.ts` says so at the field itself. A touch is the\n * ad or the link the visitor arrived by, already resolved by the plugin that\n * credits outcomes (`plugin-conversion-credit`) at the silo's door;\n * `campaignIds` is which campaigns the merchant filed the capture SURFACE\n * under, which is true of everybody who fills that form in. Folding them\n * would credit a campaign for a visitor who typed the address.\n *\n * Passed through as the silo handed it, unread: the touch's shape belongs\n * to the creditor that resolved it, and the creditor checks it again when\n * the contact or the lead is credited. Only its presence is decided here,\n * because `null` and absent mean the same thing to the writer and a caller\n * should not have to know which one it sends.\n */\nfunction conversionTouchOf(\n detail: Readonly<Record<string, unknown>> | undefined,\n): Pick<UpsertHostContactOptions, 'conversionTouch'> {\n const touch = detail?.[CONVERSION_TOUCH_DETAIL]\n return touch && typeof touch === 'object'\n ? { conversionTouch: touch as PluginConversionTouch }\n : {}\n}\n\n/**\n * The silo's word for its door, as a source the CRM stores.\n *\n * ⚠️ `ContactSource` is a CLOSED union and the contract's `source` is an open\n * string, deliberately: a silo declares its door with\n * `registerPluginContactSource` rather than core enumerating every plugin's.\n * The two meet here, and a word outside the union is passed through rather\n * than rejected — refusing it would lose a third-party plugin's capture over\n * a label, and the capture is the part that matters.\n *\n * ⛔ What it costs, until the union is widened: the console's source filter\n * and `SOURCE_LABELS` key on these values, so an undeclared word renders raw\n * and matches no filter. Every first-party silo uses a word in the union, so\n * nothing does that today.\n */\nexport function contactSourceOf(source: string): ContactSource {\n const word = String(source ?? '').trim()\n if (!Object.hasOwn(CONTACT_SOURCE_LABELS, word)) {\n // Said once, where somebody debugging an unlabelled row will find it.\n // Not a refusal: the capture is worth more than the label.\n console.warn(\n `crm contact capture: source \"${word}\" has no label, so it will render ` +\n 'raw and match no filter in the console.',\n )\n }\n return word as ContactSource\n}\n\n/** What a refused caller is told — customer-safe, and never a key. */\nfunction refusalText(reason: 'invalid-email' | 'band' | 'erased' | 'error'): string {\n switch (reason) {\n case 'invalid-email':\n return 'That address could not be read, so no contact was recorded.'\n case 'band':\n return 'This workspace is at the number of contacts its plan holds.'\n case 'erased':\n return 'This person was erased from this workspace and was not recreated.'\n default:\n return 'The contact could not be recorded. Nothing else was affected.'\n }\n}\n"],"names":["CONTACT_SOURCE_LABELS","formLeadSource","normalizePhone","isCrmLeadOpen","normalizeContactEmail","personKey","captureHostContact","contactCaptureActor","emitHostEvent","addHostLeadOutcome","findContactByEmail","firebaseAdmin","orgDataCollectionForHost","readLeadForHost","CONVERSION_TOUCH_DETAIL","convertOpenLeadOntoContact","crmRecordOriginWriter","captureContactForCrm","request","surface","identity","email","refusedEmail","heldAsContact","stampedAfter","fileLead","openLeadFor","verdict","captureOnContact","ok","stampOrigin","record","hostId","contactId","by","interaction","source","purchaseCents","error","console","reason","stamp","origin","firstTouchOnly","filed","refusalText","contacts","snapshot","key","lead","data","convertedContactId","hostRef","app","firestore","collection","doc","created","formId","entryPointOf","detail","outcome","name","marketingConsent","disclosureOf","campaignsFiledUnder","phoneFillOf","conversionTouchOf","conversionTouch","touch","stored","cause","actor","leadCreatedPayload","leadId","campaignIds","sourceAdded","String","trim","length","join","contactSourceOf","atMs","undefined","refId","summary","tags","purchaseCurrency","lifecycleFloor","initialLifecycleStage","profile","facet","phoneFill","facetFill","phone","refused","disclosedConsentGroup","raw","profileFill","text","value","path","ids","containers","word","Object","hasOwn","warn"],"mappings":"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"}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import { type CrmActivity, type CrmEmailDeliveryState } from '@aglyn/aglyn/server';
|
|
18
|
+
/**
|
|
19
|
+
* The activity row a one-to-one email is logged as, on the server side
|
|
20
|
+
* (AGL-2615): where it lives, how it is written, and how the delivery
|
|
21
|
+
* webhook moves its state.
|
|
22
|
+
*
|
|
23
|
+
* Two writers log a sent email — the console's `crm/email-send` route and
|
|
24
|
+
* the `sendEmail` automation step — and one reader advances it, the Resend
|
|
25
|
+
* event webhook. The reader has to find the row from nothing but the tags
|
|
26
|
+
* on the message, so the id is minted BEFORE the send and stamped on it,
|
|
27
|
+
* and the row is written only after the provider accepted the message. A
|
|
28
|
+
* row written first would be an "email" on the timeline for a message that
|
|
29
|
+
* never left.
|
|
30
|
+
*
|
|
31
|
+
* Firestore is a parameter throughout, as in `crm-records.ts`: the callers
|
|
32
|
+
* hold one, and a module that reached for the Admin app itself would drag
|
|
33
|
+
* it into every spec that only wants the arithmetic.
|
|
34
|
+
*/
|
|
35
|
+
/** `orgs/{orgId}/crmActivities/{activityId}`. */
|
|
36
|
+
export declare function crmActivityRef(firestore: FirebaseFirestore.Firestore, orgId: string, activityId: string): FirebaseFirestore.DocumentReference;
|
|
37
|
+
/**
|
|
38
|
+
* A fresh reference for the row a send is about to earn — `doc()` with no
|
|
39
|
+
* id mints one locally and reads nothing, so the id can ride the message
|
|
40
|
+
* as a tag before anything is written.
|
|
41
|
+
*/
|
|
42
|
+
export declare function newCrmActivityRef(firestore: FirebaseFirestore.Firestore, orgId: string): FirebaseFirestore.DocumentReference;
|
|
43
|
+
/**
|
|
44
|
+
* Writes the row a sent email is logged as, stamped with the server clock.
|
|
45
|
+
*
|
|
46
|
+
* `set` rather than `add`, because the id was minted ahead of the send and
|
|
47
|
+
* is already on the message; a second id here would be the one the webhook
|
|
48
|
+
* could not find.
|
|
49
|
+
*/
|
|
50
|
+
export declare function writeCrmEmailActivity(ref: FirebaseFirestore.DocumentReference, activity: CrmActivity): Promise<void>;
|
|
51
|
+
/**
|
|
52
|
+
* Which delivery state one normalized delivery event moves a row to, or
|
|
53
|
+
* `null` for an event that moves nothing.
|
|
54
|
+
*
|
|
55
|
+
* Takes the log's own vocabulary (`EmailDeliveryEventType`) rather than the
|
|
56
|
+
* provider's wire strings: the mail provider's `deliveryEvents` is the one place
|
|
57
|
+
* that reads those, and this module must not become a second. The five
|
|
58
|
+
* states the timeline shows share the log's names exactly, so the mapping
|
|
59
|
+
* is membership; `delayed` and `failed` are the log's own and the row keeps
|
|
60
|
+
* whatever it held.
|
|
61
|
+
*/
|
|
62
|
+
export declare function crmEmailDeliveryStateForEvent(type: string | null | undefined): CrmEmailDeliveryState | null;
|
|
63
|
+
export type CrmEmailDeliveryOutcome =
|
|
64
|
+
/** The row moved to the incoming state. */
|
|
65
|
+
'advanced'
|
|
66
|
+
/** The row already held this state or a later one; nothing written. */
|
|
67
|
+
| 'unchanged'
|
|
68
|
+
/** No email activity by that id in that org; nothing written. */
|
|
69
|
+
| 'missing'
|
|
70
|
+
/** The read or the write threw; logged, and nothing is promised. */
|
|
71
|
+
| 'failed';
|
|
72
|
+
/**
|
|
73
|
+
* One delivery event, onto the activity row it names (AGL-2615).
|
|
74
|
+
*
|
|
75
|
+
* A TRANSACTION, because the rule is "keep the later state" and two events
|
|
76
|
+
* for one message routinely arrive together — `delivered` and `opened`
|
|
77
|
+
* within the same second from a mail client that fetches images on
|
|
78
|
+
* receipt. Two plain read-then-writes interleaved would let the earlier
|
|
79
|
+
* event land last and print "Delivered" over an open that already
|
|
80
|
+
* happened; inside a transaction the second read sees the first write and
|
|
81
|
+
* `nextCrmEmailDeliveryState` answers "unchanged".
|
|
82
|
+
*
|
|
83
|
+
* A row that is not an email activity is left alone: the tag named it, but
|
|
84
|
+
* a delivery state on a call log would be a chip the timeline has no way to
|
|
85
|
+
* explain. **Never throws** — the webhook answers the provider 200 whatever
|
|
86
|
+
* happens here, so a failure is logged and reported rather than raised.
|
|
87
|
+
*/
|
|
88
|
+
export declare function recordCrmEmailDelivery(firestore: FirebaseFirestore.Firestore, input: {
|
|
89
|
+
orgId: string;
|
|
90
|
+
activityId: string;
|
|
91
|
+
state: CrmEmailDeliveryState;
|
|
92
|
+
/** When the provider says it happened, epoch ms. */
|
|
93
|
+
atMs: number;
|
|
94
|
+
/**
|
|
95
|
+
* What the receiving server said (AGL-3245), for a failure: the bounce's
|
|
96
|
+
* diagnostic, scrubbed of the address. Kept beside the state as
|
|
97
|
+
* `deliveryDetail`, so the timeline can say why the message did not land.
|
|
98
|
+
*/
|
|
99
|
+
detail?: string | null;
|
|
100
|
+
}): Promise<CrmEmailDeliveryOutcome>;
|
|
101
|
+
/**
|
|
102
|
+
* `orgs/{orgId}/crmActivities/{id}` for a CAPTURED message (AGL-2657), with
|
|
103
|
+
* the id derived from the message's key — its `Message-ID`, else the
|
|
104
|
+
* provider's id — so a second delivery of one message addresses the row
|
|
105
|
+
* the first delivery wrote. A hash rather than the key itself, because a
|
|
106
|
+
* `Message-ID` carries `<`, `@` and `/`, which a document id may not.
|
|
107
|
+
*/
|
|
108
|
+
export declare function crmCapturedEmailActivityRef(firestore: FirebaseFirestore.Firestore, orgId: string, key: string): FirebaseFirestore.DocumentReference;
|
|
109
|
+
/** What a create answered: the row is new, or it was already there. */
|
|
110
|
+
export type CrmEmailActivityCreateOutcome = 'created' | 'duplicate';
|
|
111
|
+
/**
|
|
112
|
+
* Writes a captured message's row ONCE: `create()`, which refuses a
|
|
113
|
+
* document that already exists, is what makes two deliveries of one
|
|
114
|
+
* message one row even when they arrive together — the second create is
|
|
115
|
+
* the one Firestore refuses, and this answers `duplicate` for it. Any
|
|
116
|
+
* other failure is the caller's.
|
|
117
|
+
*/
|
|
118
|
+
export declare function createCrmEmailActivity(ref: FirebaseFirestore.DocumentReference, activity: CrmActivity): Promise<CrmEmailActivityCreateOutcome>;
|