@aglyn/plugins-outreach 1.0.0-beta.164 → 1.0.0-beta.166

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enroll-routes.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 CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n normalizeCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n CRM_COLLECTIONS,\n crmLeadStatus,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { buildOutreachEnrollment, planOutreachFirstDue } from '../engine/enrollment-state'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport {\n decideOutreachEnrollment,\n previewOutreachPerson,\n readOutreachEnrollCandidates,\n type OutreachEnrollCandidate,\n type OutreachGateQuestion,\n} from '../enrollment/enroll-people'\nimport { readOutreachGateLookups } from '../enrollment/gate-lookups'\nimport { effectiveOutreachAllowedCountries } from '../model/compliance-settings'\nimport {\n OUTREACH_ENROLL_BATCH_MAX,\n type OutreachEnrollOutcome,\n type OutreachEnrollPreviewResponse,\n type OutreachEnrollResponse,\n type OutreachPersonRef,\n} from '../model/outreach-api'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachEnrollmentId,\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller, type OutreachRouteExtraPermission } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ENROLLING PEOPLE IN A SEQUENCE (AGL-2980): `outreach/enroll/preview` and\n * `outreach/enroll`.\n *\n * The preview reads the people a saved Contacts view or a search named —\n * at most {@link OUTREACH_ENROLL_BATCH_MAX} — and says where each stands:\n * eligible, blocked with the gate's own sentence, or waiting on the rep for\n * a personal line and the attestations only they can make. Confirm reads\n * everything again and asks the gates again with what the rep supplied,\n * because a preview is a picture of a moment: someone can unsubscribe\n * between the two, and a request can claim anything. Nothing the preview\n * answered is trusted by the enroll.\n *\n * Both read the CRM's people, so both ask for `data.manage` beside\n * `outreach.use` — the permission the contacts' own rules read with, so\n * Outreach is no side door into the address book.\n *\n * Only an ACTIVE sequence takes people: enrolling schedules the first step\n * in the mailbox's hours, and a draft's steps and a paused sequence's timing\n * are not yet what will be sent.\n */\n\n/** The permission enrolling reads the CRM with. */\nexport const OUTREACH_READS_PEOPLE: OutreachRouteExtraPermission = {\n key: 'data.manage',\n refusal: \"Enrolling reads your CRM's contacts, and your role does not include Manage data.\",\n}\n\n/** Everything enrolling reaches beyond the routes' own dependencies. */\nexport interface OutreachEnrollRouteDeps extends OutreachRouteDeps {\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n crmViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n}\n\nexport interface OutreachEnrollRoutes {\n preview: PluginWebApiHandler\n confirm: PluginWebApiHandler\n}\n\n/** The activity line an enroll writes. */\nexport function outreachEnrolledActivity(count: number): string {\n return count === 1\n ? 'Enrolled 1 person in a sequence'\n : `Enrolled ${count} people in a sequence`\n}\n\n/** Lookups for a person the gates will never be asked about: every one unchecked. */\nconst UNCHECKED: OutreachGateLookups = {\n platformSuppressed: null,\n hostSuppressed: null,\n salesTopicState: null,\n doNotContact: null,\n doNotContactDomain: null,\n workspaceMembers: null,\n openEnrollments: null,\n hasInboundEmail: null,\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\nasync function loadActiveSequence(\n firestore: Firestore,\n orgId: string,\n rawSequenceId: unknown,\n): Promise<Response | OutreachSequence> {\n const sequenceId = readOutreachDocumentId(rawSequenceId)\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to enroll people in.')\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', 'This sequence is archived and takes no one new.')\n }\n if (sequence.status !== 'active') {\n return outreachRefusal(409, 'sequence-not-active', 'Activate this sequence before enrolling people.')\n }\n return sequence\n}\n\n/** What every person in one request is judged against. */\ninterface EnrollContext {\n settings: OutreachGateQuestion['settings']\n contactGroupId: string\n siteName: string\n}\n\nasync function enrollContext(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n): Promise<EnrollContext> {\n const [orgSettings, host] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n firestore.collection('hosts').doc(sequence.hostId).get(),\n ])\n return {\n // The organization's countries are a ceiling over the sequence's.\n settings: {\n ...sequence.settings,\n allowedCountries: effectiveOutreachAllowedCountries(\n sequence.settings.allowedCountries,\n orgSettings.allowedCountries,\n ),\n },\n contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,\n siteName: host.exists ? String(host.get('name') ?? '') : '',\n }\n}\n\n/**\n * The candidates, the enrollments they already have here, and the gates'\n * lookups — every map keyed by the person's own id.\n *\n * \"Already in this sequence\" is answered two ways (AGL-3234): by the id the\n * person's enrollment would have, and by any enrollment in this sequence\n * that carries the person's ADDRESS. The second is what keeps a contact\n * from being enrolled beside the lead they were converted from: the lead's\n * enrollment followed them, under the lead's key, and still names them.\n */\nasync function readPeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n context: EnrollContext,\n people: readonly OutreachPersonRef[],\n): Promise<{\n candidates: OutreachEnrollCandidate[]\n enrolledHere: Set<string>\n lookups: Map<string, OutreachGateLookups>\n}> {\n const candidates = await readOutreachEnrollCandidates(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n contactGroupId: context.contactGroupId,\n people,\n })\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const existing = candidates.length\n ? await firestore.getAll(\n ...candidates.map((candidate) => enrollments.doc(outreachEnrollmentId(sequence.id, candidate.personId))),\n )\n : []\n const enrolledHere = new Set(\n candidates.filter((_candidate, index) => existing[index]?.exists).map((candidate) => candidate.personId),\n )\n const askable = candidates.filter((candidate) => candidate.contact && candidate.visible && candidate.email)\n const lookups = askable.length\n ? await readOutreachGateLookups(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n people: askable.map((candidate) => ({\n personId: candidate.personId,\n contactId: candidate.contactId || null,\n leadId: candidate.leadId,\n email: String(candidate.email),\n })),\n })\n : new Map<string, OutreachGateLookups>()\n for (const candidate of askable) {\n const open = lookups.get(candidate.personId)?.openEnrollments ?? []\n if (open.some((entry) => entry.sequenceId === sequence.id)) enrolledHere.add(candidate.personId)\n }\n return { candidates, enrolledHere, lookups }\n}\n\nconst uniqueIds = (values: unknown): string[] =>\n Array.isArray(values)\n ? [...new Set(values.map(readOutreachDocumentId).filter((id): id is string => id !== null))]\n : []\n\n/** The most leads a saved Leads view reads — the Leads list's own window. */\nconst LEADS_VIEW_WINDOW = 200\n\n/**\n * The people a saved LEADS view selects (AGL-3234): the sequence's site's\n * most recently seen leads, narrowed by the view's status clause the way\n * the Leads list narrows its window — open leads when the view names no\n * status, one status when it does, everything for `all`. A lead is the\n * site's own, so there is no address to resolve: the person key IS the id.\n */\nasync function leadsViewPeople(\n firestore: Firestore,\n sequence: OutreachSequence,\n filters: ReturnType<typeof normalizeCrmViewFilters>,\n): Promise<{ people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n const statusClause = filters.find((clause) => clause.field === 'status' && clause.op === 'equals')\n const wanted = String(statusClause?.value ?? 'open')\n const window = await firestore\n .collection('hosts')\n .doc(sequence.hostId)\n .collection('leads')\n .orderBy('lastSeenAtMs', 'desc')\n .limit(LEADS_VIEW_WINDOW + 1)\n .get()\n const matching = window.docs.slice(0, LEADS_VIEW_WINDOW).filter((doc) => {\n const lead = doc.data() as Record<string, unknown>\n if (wanted === 'all') return true\n if (wanted === 'open') return isCrmLeadOpen(lead as never) && !lead['convertedContactId']\n return crmLeadStatus(lead as never) === wanted\n })\n return {\n people: matching.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((doc) => ({ kind: 'lead', id: doc.id })),\n total: matching.length,\n truncated: matching.length > OUTREACH_ENROLL_BATCH_MAX || window.docs.length > LEADS_VIEW_WINDOW,\n }\n}\n\nexport function createOutreachEnrollRoutes(deps: OutreachEnrollRouteDeps): OutreachEnrollRoutes {\n /**\n * The person joins the sequence's campaigns (AGL-3254), the moment they\n * are enrolled: `arrayUnion` on the lead's own `campaignIds`, or on the\n * contact's facet for the sequence's site — the same field, the same\n * shape, every other campaign member carries — and `enrolled` credited\n * to each campaign. After the enrollment is created and never before:\n * a person the create refused joins nothing. Neither write may fail the\n * enrollment, which already exists; a membership that could not be\n * stamped is one the list's bulk bar can add by hand.\n */\n async function joinSequenceCampaigns(\n firestore: Firestore,\n orgId: string,\n sequence: OutreachSequence,\n contactGroupId: string,\n candidate: OutreachEnrollCandidate,\n nowMs: number,\n ): Promise<void> {\n const campaignIds = normalizeCampaignIds(sequence.campaignIds)\n if (!campaignIds.length) return\n try {\n const ref =\n candidate.target === 'lead' && candidate.leadId\n ? firestore.collection('hosts').doc(sequence.hostId).collection('leads').doc(candidate.leadId)\n : candidate.contactId\n ? firestore.collection('orgs').doc(orgId).collection('contacts').doc(candidate.contactId)\n : null\n if (ref) {\n const field =\n candidate.target === 'lead' ? CAMPAIGN_MEMBERSHIP_FIELD : contactCampaignFieldPath(contactGroupId)\n await ref.update({ [field]: FieldValue.arrayUnion(...campaignIds), updatedAt: FieldValue.serverTimestamp() })\n }\n } catch (error) {\n console.error('[outreach] the enrolled person could not join the sequence’s campaigns', error)\n }\n try {\n await deps.creditCampaign({ hostId: sequence.hostId, campaignIds, outcome: 'enrolled', atMs: nowMs })\n } catch (error) {\n console.error('[outreach] the enrollment could not be credited to the sequence’s campaigns', error)\n }\n }\n\n /**\n * The people a source names, the batch limit applied: every contact a\n * search picked, every lead picked from the sequence's site, or the\n * people a saved Contacts or Leads view selects there — contacts found by\n * address, leads by their own key.\n */\n async function sourcePeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n source: Record<string, unknown>,\n ): Promise<Response | { people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n if (source['kind'] === 'contacts' || source['kind'] === 'leads') {\n const kind = source['kind'] === 'leads' ? 'lead' : 'contact'\n const ids = uniqueIds(kind === 'lead' ? source['leadIds'] : source['contactIds'])\n if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n return {\n people: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((id) => ({ kind, id })),\n total: ids.length,\n truncated: ids.length > OUTREACH_ENROLL_BATCH_MAX,\n }\n }\n if (source['kind'] !== 'view') {\n return outreachRefusal(400, 'invalid-request', 'Enroll people from a saved view or a search.')\n }\n const viewId = readOutreachDocumentId(source['viewId'])\n const view = viewId\n ? await firestore.collection('orgs').doc(caller.orgId).collection(CRM_COLLECTIONS.views).doc(viewId).get()\n : null\n const data = view?.exists ? (view.data() as Record<string, unknown>) : null\n // A colleague's private view is theirs: it is not listed for this\n // reader, so it does not exist for them here either.\n if (\n !viewId ||\n !data ||\n !crmViewIsListed({ shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') }, caller.uid)\n ) {\n return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.')\n }\n if (data['section'] === 'leads') {\n return leadsViewPeople(firestore, sequence, normalizeCrmViewFilters(data['filters']))\n }\n if (data['section'] !== 'contacts') {\n return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts or Leads.')\n }\n const { unsupported } = dynamicListDimensionsForCrmView(normalizeCrmViewFilters(data['filters']))\n if (unsupported.length) {\n // Dropping the filter enrolling cannot read would enroll more people\n // than the view shows, so the view is refused whole.\n return outreachRefusal(\n 400,\n 'view-unsupported',\n `This view filters on ${unsupported.map((clause) => clause.label || clause.field).join(', ')}, which enrolling can't apply. Pick another view, or search.`,\n )\n }\n const { emails, complete } = await deps.crmViewEmails({ hostId: sequence.hostId, viewId })\n const ordered = [...new Set(emails)].sort()\n const contacts = firestore.collection('orgs').doc(caller.orgId).collection('contacts')\n const found = await Promise.all(\n ordered\n .slice(0, OUTREACH_ENROLL_BATCH_MAX)\n .map((email) => findContactByEmail(contacts, email, { hostId: sequence.hostId })),\n )\n return {\n people: [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))].map(\n (id) => ({ kind: 'contact', id }),\n ),\n total: ordered.length,\n truncated: ordered.length > OUTREACH_ENROLL_BATCH_MAX || !complete,\n }\n }\n\n const preview: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n const source = body['source'] && typeof body['source'] === 'object' ? (body['source'] as Record<string, unknown>) : {}\n const named = await sourcePeople(firestore, caller, sequence, source)\n if (named instanceof Response) return named\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n named.people,\n )\n const people = candidates.map((candidate) =>\n previewOutreachPerson(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n ),\n )\n return outreachOk({\n ok: true,\n people,\n total: named.total,\n truncated: named.truncated,\n } satisfies OutreachEnrollPreviewResponse)\n }\n\n const confirm: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n\n // Each person once, by whichever record they are: a contact by id, or a\n // lead by its key (AGL-3234). A person named both ways is a contact.\n const requested = new Map<\n string,\n { ref: OutreachPersonRef; personalLine: string; attestations: unknown[] }\n >()\n for (const entry of Array.isArray(body['people']) ? body['people'] : []) {\n const person = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>\n const contactId = readOutreachDocumentId(person['contactId'])\n const leadId = readOutreachDocumentId(person['leadId'])\n const ref: OutreachPersonRef | null = contactId\n ? { kind: 'contact', id: contactId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : null\n if (!ref || requested.has(ref.id)) continue\n requested.set(ref.id, {\n ref,\n personalLine: typeof person['personalLine'] === 'string' ? person['personalLine'] : '',\n attestations: Array.isArray(person['attestations']) ? person['attestations'] : [],\n })\n }\n if (!requested.size) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n if (requested.size > OUTREACH_ENROLL_BATCH_MAX) {\n return outreachRefusal(\n 400,\n 'invalid-request',\n `Enroll at most ${OUTREACH_ENROLL_BATCH_MAX} people at a time.`,\n )\n }\n\n const mailboxSnapshot = sequence.mailboxId\n ? await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get()\n : null\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n if (!mailbox || mailbox.status === 'disconnected') {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's mailbox is no longer connected. Choose another mailbox for it before enrolling people.\",\n )\n }\n const nowMs = deps.now()\n if (planOutreachFirstDue({ sequence, mailbox, enrolledAtMs: nowMs, random: deps.random }) === null) {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's sending hours never open in its mailbox's timezone. Fix the hours before enrolling people.\",\n )\n }\n\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n [...requested.values()].map((entry) => entry.ref),\n )\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const results: OutreachEnrollOutcome[] = await Promise.all(\n candidates.map(async (candidate): Promise<OutreachEnrollOutcome> => {\n const named = {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n const supplied = requested.get(candidate.personId) ?? { personalLine: '', attestations: [] }\n const decision = decideOutreachEnrollment(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n { personalLine: supplied.personalLine, attestations: supplied.attestations, uid: caller.uid, nowMs },\n )\n if (!decision.allowed || !decision.email) {\n return { ...named, email: candidate.email, outcome: 'blocked', blocks: decision.blocks }\n }\n const id = outreachEnrollmentId(sequence.id, candidate.personId)\n const enrollment = buildOutreachEnrollment({\n id,\n sequence,\n mailbox,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n contactName: candidate.name,\n email: decision.email,\n cold: decision.cold,\n personalLine: supplied.personalLine,\n attestations: decision.attestations,\n enrolledByUid: caller.uid,\n nowMs,\n random: deps.random,\n })\n try {\n await enrollments.doc(id).create(enrollment)\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n return {\n ...named,\n email: candidate.email,\n outcome: 'blocked',\n blocks: [\n {\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n },\n ],\n }\n }\n await joinSequenceCampaigns(firestore, caller.orgId, sequence, context.contactGroupId, candidate, nowMs)\n return { ...named, email: decision.email, outcome: 'enrolled', enrollmentId: id }\n }),\n )\n const enrolled = results.filter((result) => result.outcome === 'enrolled').length\n if (enrolled) {\n await deps.logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n outreachEnrolledActivity(enrolled),\n { type: OUTREACH_SEQUENCE_ACTIVITY_TARGET, id: sequence.id, name: sequence.name },\n )\n }\n return outreachOk({ ok: true, results, enrolled } satisfies OutreachEnrollResponse)\n }\n\n return { preview, confirm }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","normalizeCampaignIds","consentGroupForHost","CRM_COLLECTIONS","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","FieldValue","buildOutreachEnrollment","planOutreachFirstDue","decideOutreachEnrollment","previewOutreachPerson","readOutreachEnrollCandidates","readOutreachGateLookups","effectiveOutreachAllowedCountries","OUTREACH_ENROLL_BATCH_MAX","readOutreachComplianceSettingsDoc","outreachEnrollmentId","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_READS_PEOPLE","key","refusal","outreachEnrolledActivity","count","UNCHECKED","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","doNotContactDomain","workspaceMembers","openEnrollments","hasInboundEmail","ALREADY_EXISTS","loadActiveSequence","firestore","orgId","rawSequenceId","sequenceId","snapshot","doc","get","sequence","exists","data","undefined","status","enrollContext","caller","host","orgSettings","Promise","all","collection","hostId","settings","allowedCountries","contactGroupId","org","groupId","siteName","String","readPeople","context","people","candidates","enrollments","existing","length","getAll","map","candidate","id","personId","enrolledHere","Set","filter","_candidate","index","askable","contact","visible","email","lookups","contactId","leadId","Map","open","some","entry","add","uniqueIds","values","Array","isArray","LEADS_VIEW_WINDOW","leadsViewPeople","filters","statusClause","find","clause","field","op","wanted","value","window","orderBy","limit","matching","docs","slice","lead","kind","total","truncated","createOutreachEnrollRoutes","deps","joinSequenceCampaigns","nowMs","campaignIds","ref","target","update","arrayUnion","updatedAt","serverTimestamp","error","console","creditCampaign","outcome","atMs","sourcePeople","source","ids","viewId","view","views","shared","ownerUid","uid","unsupported","label","join","emails","complete","crmViewEmails","ordered","sort","contacts","found","preview","request","method","body","gate","Response","named","alreadyInSequence","has","ok","confirm","requested","person","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","now","enrolledAtMs","random","results","supplied","decision","allowed","blocks","enrollment","contactName","name","cold","enrolledByUid","create","code","reason","enrollmentId","enrolled","result","logOrgActivity","type"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enroll-routes.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 CAMPAIGN_MEMBERSHIP_FIELD,\n contactCampaignFieldPath,\n normalizeCampaignIds,\n} from '@aglyn/aglyn/app-utils/campaign-membership'\nimport { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { scopeTokensForHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport {\n CRM_COLLECTIONS,\n crmLeadStatus,\n crmViewIsListed,\n isCrmLeadOpen,\n normalizeCrmViewFilters,\n} from '@aglyn/aglyn/app-utils/crm'\nimport { dynamicListDimensionsForCrmView } from '@aglyn/aglyn/app-utils/dynamic-list-rule'\nimport type { PluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { findContactByEmail } from '@aglyn/tenant-data-admin/server/contact-email-index'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachEnrolledEntry } from '../engine/enrollment-activity'\nimport { buildOutreachEnrollment, planOutreachFirstDue } from '../engine/enrollment-state'\nimport type { OutreachGateLookups } from '../engine/gates'\nimport {\n decideOutreachEnrollment,\n previewOutreachPerson,\n readOutreachEnrollCandidates,\n type OutreachEnrollCandidate,\n type OutreachGateQuestion,\n} from '../enrollment/enroll-people'\nimport { readOutreachGateLookups } from '../enrollment/gate-lookups'\nimport { effectiveOutreachAllowedCountries } from '../model/compliance-settings'\nimport {\n OUTREACH_ENROLL_BATCH_MAX,\n type OutreachEnrollOutcome,\n type OutreachEnrollPreviewResponse,\n type OutreachEnrollResponse,\n type OutreachPersonRef,\n} from '../model/outreach-api'\nimport type { OutreachSequence } from '../model/outreach.types'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport { fileOutreachNote } from '../runtime/timeline'\nimport {\n outreachEnrollmentId,\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller, type OutreachRouteExtraPermission } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ENROLLING PEOPLE IN A SEQUENCE (AGL-2980): `outreach/enroll/preview` and\n * `outreach/enroll`.\n *\n * The preview reads the people a saved Contacts view or a search named —\n * at most {@link OUTREACH_ENROLL_BATCH_MAX} — and says where each stands:\n * eligible, blocked with the gate's own sentence, or waiting on the rep for\n * a personal line and the attestations only they can make. Confirm reads\n * everything again and asks the gates again with what the rep supplied,\n * because a preview is a picture of a moment: someone can unsubscribe\n * between the two, and a request can claim anything. Nothing the preview\n * answered is trusted by the enroll.\n *\n * Both read the CRM's people, so both ask for `data.manage` beside\n * `outreach.use` — the permission the contacts' own rules read with, so\n * Outreach is no side door into the address book.\n *\n * Only an ACTIVE sequence takes people: enrolling schedules the first step\n * in the mailbox's hours, and a draft's steps and a paused sequence's timing\n * are not yet what will be sent.\n */\n\n/** The permission enrolling reads the CRM with. */\nexport const OUTREACH_READS_PEOPLE: OutreachRouteExtraPermission = {\n key: 'data.manage',\n refusal: \"Enrolling reads your CRM's contacts, and your role does not include Manage data.\",\n}\n\n/** Everything enrolling reaches beyond the routes' own dependencies. */\nexport interface OutreachEnrollRouteDeps extends OutreachRouteDeps {\n /**\n * The addresses a saved Contacts view selects among one site's contacts,\n * read the way the dynamic-list sweep reads one, and whether the read\n * reached the whole view before its budget.\n */\n crmViewEmails(input: { hostId: string; viewId: string }): Promise<{ emails: string[]; complete: boolean }>\n /**\n * The workspace's record system on the timeline seam (AGL-3274), when a\n * plugin keeps one: the enroll files \"Enrolled in\" on the person's record\n * — the runtime's `timeline`, reached the same way.\n */\n timeline(): PluginRecordTimelineWriter | null\n}\n\nexport interface OutreachEnrollRoutes {\n preview: PluginWebApiHandler\n confirm: PluginWebApiHandler\n}\n\n/** The activity line an enroll writes. */\nexport function outreachEnrolledActivity(count: number): string {\n return count === 1\n ? 'Enrolled 1 person in a sequence'\n : `Enrolled ${count} people in a sequence`\n}\n\n/** Lookups for a person the gates will never be asked about: every one unchecked. */\nconst UNCHECKED: OutreachGateLookups = {\n platformSuppressed: null,\n hostSuppressed: null,\n salesTopicState: null,\n doNotContact: null,\n doNotContactDomain: null,\n workspaceMembers: null,\n openEnrollments: null,\n hasInboundEmail: null,\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** gRPC `ALREADY_EXISTS`, which `create()` rejects with when the document is there. */\nconst ALREADY_EXISTS = 6\n\nasync function loadActiveSequence(\n firestore: Firestore,\n orgId: string,\n rawSequenceId: unknown,\n): Promise<Response | OutreachSequence> {\n const sequenceId = readOutreachDocumentId(rawSequenceId)\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to enroll people in.')\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', 'This sequence is archived and takes no one new.')\n }\n if (sequence.status !== 'active') {\n return outreachRefusal(409, 'sequence-not-active', 'Activate this sequence before enrolling people.')\n }\n return sequence\n}\n\n/** What every person in one request is judged against. */\ninterface EnrollContext {\n settings: OutreachGateQuestion['settings']\n contactGroupId: string\n siteName: string\n}\n\nasync function enrollContext(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n): Promise<EnrollContext> {\n const [orgSettings, host] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n firestore.collection('hosts').doc(sequence.hostId).get(),\n ])\n return {\n // The organization's countries are a ceiling over the sequence's.\n settings: {\n ...sequence.settings,\n allowedCountries: effectiveOutreachAllowedCountries(\n sequence.settings.allowedCountries,\n orgSettings.allowedCountries,\n ),\n },\n contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,\n siteName: host.exists ? String(host.get('name') ?? '') : '',\n }\n}\n\n/**\n * The candidates, the enrollments they already have here, and the gates'\n * lookups — every map keyed by the person's own id.\n *\n * \"Already in this sequence\" is answered two ways (AGL-3234): by the id the\n * person's enrollment would have, and by any enrollment in this sequence\n * that carries the person's ADDRESS. The second is what keeps a contact\n * from being enrolled beside the lead they were converted from: the lead's\n * enrollment followed them, under the lead's key, and still names them.\n */\nasync function readPeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n context: EnrollContext,\n people: readonly OutreachPersonRef[],\n): Promise<{\n candidates: OutreachEnrollCandidate[]\n enrolledHere: Set<string>\n lookups: Map<string, OutreachGateLookups>\n}> {\n const candidates = await readOutreachEnrollCandidates(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n contactGroupId: context.contactGroupId,\n people,\n })\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const existing = candidates.length\n ? await firestore.getAll(\n ...candidates.map((candidate) => enrollments.doc(outreachEnrollmentId(sequence.id, candidate.personId))),\n )\n : []\n const enrolledHere = new Set(\n candidates.filter((_candidate, index) => existing[index]?.exists).map((candidate) => candidate.personId),\n )\n const askable = candidates.filter((candidate) => candidate.contact && candidate.visible && candidate.email)\n const lookups = askable.length\n ? await readOutreachGateLookups(firestore, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n people: askable.map((candidate) => ({\n personId: candidate.personId,\n contactId: candidate.contactId || null,\n leadId: candidate.leadId,\n email: String(candidate.email),\n })),\n })\n : new Map<string, OutreachGateLookups>()\n for (const candidate of askable) {\n const open = lookups.get(candidate.personId)?.openEnrollments ?? []\n if (open.some((entry) => entry.sequenceId === sequence.id)) enrolledHere.add(candidate.personId)\n }\n return { candidates, enrolledHere, lookups }\n}\n\nconst uniqueIds = (values: unknown): string[] =>\n Array.isArray(values)\n ? [...new Set(values.map(readOutreachDocumentId).filter((id): id is string => id !== null))]\n : []\n\n/** The most leads a saved Leads view reads — the Leads list's own window. */\nconst LEADS_VIEW_WINDOW = 200\n\n/**\n * The people a saved LEADS view selects (AGL-3234): the sequence's site's\n * most recently seen leads, narrowed by the view's status clause the way\n * the Leads list narrows its window — open leads when the view names no\n * status, one status when it does, everything for `all`. The person key IS\n * the id, so there is no address to resolve — and since AGL-3275 the window is\n * narrowed by `visibleTo` rather than by the collection's parent.\n */\nasync function leadsViewPeople(\n firestore: Firestore,\n orgId: string,\n sequence: OutreachSequence,\n filters: ReturnType<typeof normalizeCrmViewFilters>,\n): Promise<{ people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n const statusClause = filters.find((clause) => clause.field === 'status' && clause.op === 'equals')\n const wanted = String(statusClause?.value ?? 'open')\n /*\n * SCOPED (AGL-3275). The collection is org-wide, so the window is narrowed\n * to what the sequence's site may see before it is ordered — otherwise an\n * agency's sequence would offer another client's people to enroll. The\n * composite index for `visibleTo array-contains-any` + `lastSeenAtMs desc`\n * is in `cloud/firestore.indexes.json`.\n */\n const window = await firestore\n .collection('orgs')\n .doc(orgId)\n .collection('leads')\n .where('visibleTo', 'array-contains-any', scopeTokensForHost(sequence.hostId))\n .orderBy('lastSeenAtMs', 'desc')\n .limit(LEADS_VIEW_WINDOW + 1)\n .get()\n const matching = window.docs.slice(0, LEADS_VIEW_WINDOW).filter((doc) => {\n const lead = doc.data() as Record<string, unknown>\n if (wanted === 'all') return true\n if (wanted === 'open') return isCrmLeadOpen(lead as never) && !lead['convertedContactId']\n return crmLeadStatus(lead as never) === wanted\n })\n return {\n people: matching.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((doc) => ({ kind: 'lead', id: doc.id })),\n total: matching.length,\n truncated: matching.length > OUTREACH_ENROLL_BATCH_MAX || window.docs.length > LEADS_VIEW_WINDOW,\n }\n}\n\nexport function createOutreachEnrollRoutes(deps: OutreachEnrollRouteDeps): OutreachEnrollRoutes {\n /**\n * The person joins the sequence's campaigns (AGL-3254), the moment they\n * are enrolled: `arrayUnion` on the lead's own `campaignIds`, or on the\n * contact's facet for the sequence's site — the same field, the same\n * shape, every other campaign member carries — and `enrolled` credited\n * to each campaign. After the enrollment is created and never before:\n * a person the create refused joins nothing. Neither write may fail the\n * enrollment, which already exists; a membership that could not be\n * stamped is one the list's bulk bar can add by hand.\n */\n async function joinSequenceCampaigns(\n firestore: Firestore,\n orgId: string,\n sequence: OutreachSequence,\n contactGroupId: string,\n candidate: OutreachEnrollCandidate,\n nowMs: number,\n ): Promise<void> {\n const campaignIds = normalizeCampaignIds(sequence.campaignIds)\n if (!campaignIds.length) return\n try {\n const ref =\n candidate.target === 'lead' && candidate.leadId\n ? firestore.collection('orgs').doc(orgId).collection('leads').doc(candidate.leadId)\n : candidate.contactId\n ? firestore.collection('orgs').doc(orgId).collection('contacts').doc(candidate.contactId)\n : null\n if (ref) {\n const field =\n candidate.target === 'lead' ? CAMPAIGN_MEMBERSHIP_FIELD : contactCampaignFieldPath(contactGroupId)\n await ref.update({ [field]: FieldValue.arrayUnion(...campaignIds), updatedAt: FieldValue.serverTimestamp() })\n }\n } catch (error) {\n console.error('[outreach] the enrolled person could not join the sequence’s campaigns', error)\n }\n try {\n await deps.creditCampaign({ hostId: sequence.hostId, campaignIds, outcome: 'enrolled', atMs: nowMs })\n } catch (error) {\n console.error('[outreach] the enrollment could not be credited to the sequence’s campaigns', error)\n }\n }\n\n /**\n * The names of the sequence's campaigns as they stand, for the entry the\n * enroll files on the person's record (AGL-3274). Read once per request,\n * not per person; a container that is gone answers nothing and the entry\n * names the rest.\n */\n async function sequenceCampaignNames(firestore: Firestore, sequence: OutreachSequence): Promise<string[]> {\n const campaignIds = normalizeCampaignIds(sequence.campaignIds)\n if (!campaignIds.length) return []\n try {\n const containers = firestore.collection('hosts').doc(sequence.hostId).collection('emailCampaigns')\n const found = await firestore.getAll(...campaignIds.map((id) => containers.doc(id)))\n return found.map((snapshot) => String(snapshot.get('name') ?? '').trim()).filter(Boolean)\n } catch (error) {\n console.error('[outreach] the sequence’s campaigns could not be named for the record', error)\n return []\n }\n }\n\n /**\n * The people a source names, the batch limit applied: every contact a\n * search picked, every lead picked from the sequence's site, or the\n * people a saved Contacts or Leads view selects there — contacts found by\n * address, leads by their own key.\n */\n async function sourcePeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n source: Record<string, unknown>,\n ): Promise<Response | { people: OutreachPersonRef[]; total: number; truncated: boolean }> {\n if (source['kind'] === 'contacts' || source['kind'] === 'leads') {\n const kind = source['kind'] === 'leads' ? 'lead' : 'contact'\n const ids = uniqueIds(kind === 'lead' ? source['leadIds'] : source['contactIds'])\n if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n return {\n people: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX).map((id) => ({ kind, id })),\n total: ids.length,\n truncated: ids.length > OUTREACH_ENROLL_BATCH_MAX,\n }\n }\n if (source['kind'] !== 'view') {\n return outreachRefusal(400, 'invalid-request', 'Enroll people from a saved view or a search.')\n }\n const viewId = readOutreachDocumentId(source['viewId'])\n const view = viewId\n ? await firestore.collection('orgs').doc(caller.orgId).collection(CRM_COLLECTIONS.views).doc(viewId).get()\n : null\n const data = view?.exists ? (view.data() as Record<string, unknown>) : null\n // A colleague's private view is theirs: it is not listed for this\n // reader, so it does not exist for them here either.\n if (\n !viewId ||\n !data ||\n !crmViewIsListed({ shared: data['shared'] === true, ownerUid: String(data['ownerUid'] ?? '') }, caller.uid)\n ) {\n return outreachRefusal(404, 'view-not-found', 'That saved view no longer exists.')\n }\n if (data['section'] === 'leads') {\n return leadsViewPeople(firestore, caller.orgId, sequence, normalizeCrmViewFilters(data['filters']))\n }\n if (data['section'] !== 'contacts') {\n return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts or Leads.')\n }\n const { unsupported } = dynamicListDimensionsForCrmView(normalizeCrmViewFilters(data['filters']))\n if (unsupported.length) {\n // Dropping the filter enrolling cannot read would enroll more people\n // than the view shows, so the view is refused whole.\n return outreachRefusal(\n 400,\n 'view-unsupported',\n `This view filters on ${unsupported.map((clause) => clause.label || clause.field).join(', ')}, which enrolling can't apply. Pick another view, or search.`,\n )\n }\n const { emails, complete } = await deps.crmViewEmails({ hostId: sequence.hostId, viewId })\n const ordered = [...new Set(emails)].sort()\n const contacts = firestore.collection('orgs').doc(caller.orgId).collection('contacts')\n const found = await Promise.all(\n ordered\n .slice(0, OUTREACH_ENROLL_BATCH_MAX)\n .map((email) => findContactByEmail(contacts, email, { hostId: sequence.hostId })),\n )\n return {\n people: [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))].map(\n (id) => ({ kind: 'contact', id }),\n ),\n total: ordered.length,\n truncated: ordered.length > OUTREACH_ENROLL_BATCH_MAX || !complete,\n }\n }\n\n const preview: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n const source = body['source'] && typeof body['source'] === 'object' ? (body['source'] as Record<string, unknown>) : {}\n const named = await sourcePeople(firestore, caller, sequence, source)\n if (named instanceof Response) return named\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n named.people,\n )\n const people = candidates.map((candidate) =>\n previewOutreachPerson(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n ),\n )\n return outreachOk({\n ok: true,\n people,\n total: named.total,\n truncated: named.truncated,\n } satisfies OutreachEnrollPreviewResponse)\n }\n\n const confirm: PluginWebApiHandler = async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const caller = await outreachRouteGate(request, body['orgId'], deps.gate, [OUTREACH_READS_PEOPLE])\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n const sequence = await loadActiveSequence(firestore, caller.orgId, body['sequenceId'])\n if (sequence instanceof Response) return sequence\n\n // Each person once, by whichever record they are: a contact by id, or a\n // lead by its key (AGL-3234). A person named both ways is a contact.\n const requested = new Map<\n string,\n { ref: OutreachPersonRef; personalLine: string; attestations: unknown[] }\n >()\n for (const entry of Array.isArray(body['people']) ? body['people'] : []) {\n const person = (entry && typeof entry === 'object' ? entry : {}) as Record<string, unknown>\n const contactId = readOutreachDocumentId(person['contactId'])\n const leadId = readOutreachDocumentId(person['leadId'])\n const ref: OutreachPersonRef | null = contactId\n ? { kind: 'contact', id: contactId }\n : leadId\n ? { kind: 'lead', id: leadId }\n : null\n if (!ref || requested.has(ref.id)) continue\n requested.set(ref.id, {\n ref,\n personalLine: typeof person['personalLine'] === 'string' ? person['personalLine'] : '',\n attestations: Array.isArray(person['attestations']) ? person['attestations'] : [],\n })\n }\n if (!requested.size) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n if (requested.size > OUTREACH_ENROLL_BATCH_MAX) {\n return outreachRefusal(\n 400,\n 'invalid-request',\n `Enroll at most ${OUTREACH_ENROLL_BATCH_MAX} people at a time.`,\n )\n }\n\n const mailboxSnapshot = sequence.mailboxId\n ? await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get()\n : null\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n if (!mailbox || mailbox.status === 'disconnected') {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's mailbox is no longer connected. Choose another mailbox for it before enrolling people.\",\n )\n }\n const nowMs = deps.now()\n if (planOutreachFirstDue({ sequence, mailbox, enrolledAtMs: nowMs, random: deps.random }) === null) {\n return outreachRefusal(\n 409,\n 'mailbox-unavailable',\n \"This sequence's sending hours never open in its mailbox's timezone. Fix the hours before enrolling people.\",\n )\n }\n\n const context = await enrollContext(firestore, caller, sequence)\n const { candidates, enrolledHere, lookups } = await readPeople(\n firestore,\n caller,\n sequence,\n context,\n [...requested.values()].map((entry) => entry.ref),\n )\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const campaignNames = await sequenceCampaignNames(firestore, sequence)\n const results: OutreachEnrollOutcome[] = await Promise.all(\n candidates.map(async (candidate): Promise<OutreachEnrollOutcome> => {\n const named = {\n personId: candidate.personId,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n }\n const supplied = requested.get(candidate.personId) ?? { personalLine: '', attestations: [] }\n const decision = decideOutreachEnrollment(\n {\n candidate,\n lookups: lookups.get(candidate.personId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.personId) },\n { personalLine: supplied.personalLine, attestations: supplied.attestations, uid: caller.uid, nowMs },\n )\n if (!decision.allowed || !decision.email) {\n return { ...named, email: candidate.email, outcome: 'blocked', blocks: decision.blocks }\n }\n const id = outreachEnrollmentId(sequence.id, candidate.personId)\n const enrollment = buildOutreachEnrollment({\n id,\n sequence,\n mailbox,\n target: candidate.target,\n contactId: candidate.contactId,\n leadId: candidate.leadId,\n contactName: candidate.name,\n email: decision.email,\n cold: decision.cold,\n personalLine: supplied.personalLine,\n attestations: decision.attestations,\n enrolledByUid: caller.uid,\n nowMs,\n random: deps.random,\n })\n try {\n await enrollments.doc(id).create(enrollment)\n } catch (error) {\n if ((error as { code?: unknown })?.code !== ALREADY_EXISTS) throw error\n return {\n ...named,\n email: candidate.email,\n outcome: 'blocked',\n blocks: [\n {\n code: 'already_in_sequence',\n reason: \"This person has already been in this sequence, so they won't be sent its emails again.\",\n },\n ],\n }\n }\n await joinSequenceCampaigns(firestore, caller.orgId, sequence, context.contactGroupId, candidate, nowMs)\n // The person's record says so (AGL-3274): \"Enrolled in <sequence>\",\n // with the campaigns it carried them into, once per enrollment.\n const entry = outreachEnrolledEntry({ enrollmentId: id, sequenceName: sequence.name, campaignNames })\n await fileOutreachNote(deps, {\n orgId: caller.orgId,\n hostId: sequence.hostId,\n link: outreachEnrollmentLink(enrollment),\n dedupeKey: entry.dedupeKey,\n body: entry.body,\n atMs: nowMs,\n })\n return { ...named, email: decision.email, outcome: 'enrolled', enrollmentId: id }\n }),\n )\n const enrolled = results.filter((result) => result.outcome === 'enrolled').length\n if (enrolled) {\n await deps.logOrgActivity(\n caller.orgId,\n { uid: caller.uid, email: caller.email },\n outreachEnrolledActivity(enrolled),\n { type: OUTREACH_SEQUENCE_ACTIVITY_TARGET, id: sequence.id, name: sequence.name },\n )\n }\n return outreachOk({ ok: true, results, enrolled } satisfies OutreachEnrollResponse)\n }\n\n return { preview, confirm }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","contactCampaignFieldPath","normalizeCampaignIds","consentGroupForHost","scopeTokensForHost","CRM_COLLECTIONS","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","FieldValue","outreachEnrolledEntry","buildOutreachEnrollment","planOutreachFirstDue","decideOutreachEnrollment","previewOutreachPerson","readOutreachEnrollCandidates","readOutreachGateLookups","effectiveOutreachAllowedCountries","OUTREACH_ENROLL_BATCH_MAX","readOutreachComplianceSettingsDoc","fileOutreachNote","outreachEnrollmentId","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","OUTREACH_READS_PEOPLE","key","refusal","outreachEnrolledActivity","count","UNCHECKED","platformSuppressed","hostSuppressed","salesTopicState","doNotContact","doNotContactDomain","workspaceMembers","openEnrollments","hasInboundEmail","ALREADY_EXISTS","loadActiveSequence","firestore","orgId","rawSequenceId","sequenceId","snapshot","doc","get","sequence","exists","data","undefined","status","enrollContext","caller","host","orgSettings","Promise","all","collection","hostId","settings","allowedCountries","contactGroupId","org","groupId","siteName","String","readPeople","context","people","candidates","enrollments","existing","length","getAll","map","candidate","id","personId","enrolledHere","Set","filter","_candidate","index","askable","contact","visible","email","lookups","contactId","leadId","Map","open","some","entry","add","uniqueIds","values","Array","isArray","LEADS_VIEW_WINDOW","leadsViewPeople","filters","statusClause","find","clause","field","op","wanted","value","window","where","orderBy","limit","matching","docs","slice","lead","kind","total","truncated","createOutreachEnrollRoutes","deps","joinSequenceCampaigns","nowMs","campaignIds","ref","target","update","arrayUnion","updatedAt","serverTimestamp","error","console","creditCampaign","outcome","atMs","sequenceCampaignNames","containers","found","trim","Boolean","sourcePeople","source","ids","viewId","view","views","shared","ownerUid","uid","unsupported","label","join","emails","complete","crmViewEmails","ordered","sort","contacts","preview","request","method","body","gate","Response","named","alreadyInSequence","has","ok","confirm","requested","person","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","now","enrolledAtMs","random","campaignNames","results","supplied","decision","allowed","blocks","enrollment","contactName","name","cold","enrolledByUid","create","code","reason","enrollmentId","sequenceName","link","dedupeKey","enrolled","result","logOrgActivity","type"],"mappings":"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"}
@@ -77,7 +77,9 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
77
77
  sequence.hostId ? firestore.collection('hosts').doc(sequence.hostId).get() : null,
78
78
  step.templateId ? org.collection(CRM_COLLECTIONS.emailTemplates).doc(step.templateId).get() : null,
79
79
  contactId ? org.collection('contacts').doc(contactId).get() : null,
80
- leadId && sequence.hostId ? firestore.collection('hosts').doc(sequence.hostId).collection('leads').doc(leadId).get() : null
80
+ // One org row (AGL-3275); the site is still required because a lead
81
+ // preview is rendered in a site's context.
82
+ leadId && sequence.hostId ? org.collection('leads').doc(leadId).get() : null
81
83
  ]);
82
84
  const leadData = (lead == null ? void 0 : lead.exists) ? lead.data() : null;
83
85
  if (leadId && !leadData) {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/preview-routes.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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { normalizeCrmEmailTemplate } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { firstEmailStepIndex } from '../engine/sequence-validation'\nimport { leadAsContact } from '../enrollment/enroll-people'\nimport type { OutreachPreviewResponse } from '../model/outreach-api'\nimport {\n OUTREACH_SAMPLE_PERSON,\n outreachSampleMergeContext,\n previewOutreachStep,\n} from '../model/step-preview'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_READS_PEOPLE } from './enroll-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ONE EMAIL OF A SAVED SEQUENCE, AS A PERSON WOULD GET IT (AGL-2980):\n * `outreach/preview`.\n *\n * Written from what is stored — the sequence, the CRM template a step names,\n * the mailbox it sends as, the organization's footer — by the engine's own\n * composer (`previewOutreachStep`), for a real contact with the personal\n * line the rep is writing, or for a sample person. The enroll dialog shows\n * it beside each person before Confirm, so the rep reads the sentence they\n * wrote in the email it goes into.\n *\n * A real contact is read from the CRM, so naming one asks for `data.manage`\n * as enrolling does; the sample person asks for nothing more.\n */\n\nexport function createOutreachPreviewRoute(deps: OutreachRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const contactId = body['contactId'] === undefined ? null : readOutreachDocumentId(body['contactId'])\n if (body['contactId'] !== undefined && !contactId) {\n return outreachRefusal(400, 'invalid-request', 'Name the contact to preview for.')\n }\n // A lead the sequence's site holds (AGL-3234), previewed as the contact it would be.\n const leadId = body['leadId'] === undefined ? null : readOutreachDocumentId(body['leadId'])\n if (body['leadId'] !== undefined && !leadId) {\n return outreachRefusal(400, 'invalid-request', 'Name the lead to preview for.')\n }\n const caller = await outreachRouteGate(\n request,\n body['orgId'],\n deps.gate,\n contactId || leadId ? [OUTREACH_READS_PEOPLE] : [],\n )\n if (caller instanceof Response) return caller\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to preview.')\n const firestore = deps.firestore()\n const org = firestore.collection('orgs').doc(caller.orgId)\n const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n\n const requested = body['stepIndex']\n const stepIndex = typeof requested === 'number' ? requested : firstEmailStepIndex(sequence.steps)\n const step = sequence.steps[stepIndex]\n if (step?.kind !== 'email') {\n return outreachRefusal(400, 'invalid-request', 'That step does not send an email.')\n }\n\n const [orgSettings, mailboxSnapshot, host, template, contact, lead] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n sequence.mailboxId ? outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get() : null,\n sequence.hostId ? firestore.collection('hosts').doc(sequence.hostId).get() : null,\n step.templateId ? org.collection(CRM_COLLECTIONS.emailTemplates).doc(step.templateId).get() : null,\n contactId ? org.collection('contacts').doc(contactId).get() : null,\n leadId && sequence.hostId\n ? firestore.collection('hosts').doc(sequence.hostId).collection('leads').doc(leadId).get()\n : null,\n ])\n const leadData = lead?.exists ? (lead.data() as Record<string, unknown>) : null\n if (leadId && !leadData) {\n return outreachRefusal(404, 'contact-not-found', \"That lead isn't in this sequence's site's CRM.\")\n }\n const contactGroupId = consentGroupForHost(caller.org, sequence.hostId).groupId\n const contactData = contact?.exists\n ? (contact.data() as Record<string, unknown>)\n : leadData\n ? leadAsContact(leadData, contactGroupId)\n : null\n if (\n contactId &&\n (!contactData || !visibleToHost(contactData['visibleTo'] as string[] | undefined, sequence.hostId))\n ) {\n return outreachRefusal(404, 'contact-not-found', \"That contact isn't in this sequence's site's CRM.\")\n }\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n const sender = mailbox ? { name: mailbox.displayName, email: mailbox.sendAs || mailbox.email } : null\n const siteName = host?.exists ? String(host.get('name') ?? '') : ''\n const merge = contactData\n ? {\n contact: contactData,\n contactGroupId,\n lead: leadData,\n sender,\n site: { name: siteName },\n }\n : outreachSampleMergeContext({ sender, siteName })\n const personalLine =\n typeof body['personalLine'] === 'string'\n ? body['personalLine']\n : contactData\n ? ''\n : OUTREACH_SAMPLE_PERSON.personalLine\n const result = previewOutreachStep({\n steps: sequence.steps,\n stepIndex,\n orgSettings,\n merge,\n email: contactData ? (normalizeContactEmail(contactData['email']) ?? '') : OUTREACH_SAMPLE_PERSON.email,\n personalLine,\n templateBody: template?.exists\n ? normalizeCrmEmailTemplate(template.data() as Record<string, unknown>).body\n : null,\n })\n return outreachOk({\n ok: true,\n stepIndex,\n subject: result.email?.subject ?? '',\n text: result.email?.text ?? '',\n unresolvedFields: result.unresolvedFields,\n error: result.error,\n } satisfies OutreachPreviewResponse)\n }\n}\n"],"names":["consentGroupForHost","normalizeContactEmail","CRM_COLLECTIONS","normalizeCrmEmailTemplate","visibleToHost","firstEmailStepIndex","leadAsContact","OUTREACH_SAMPLE_PERSON","outreachSampleMergeContext","previewOutreachStep","readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_READS_PEOPLE","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","createOutreachPreviewRoute","deps","request","host","result","method","body","contactId","undefined","leadId","caller","gate","Response","sequenceId","firestore","org","collection","doc","orgId","snapshot","get","sequence","exists","data","requested","stepIndex","steps","step","kind","orgSettings","mailboxSnapshot","template","contact","lead","Promise","all","mailboxId","hostId","templateId","emailTemplates","leadData","contactGroupId","groupId","contactData","mailbox","sender","name","displayName","email","sendAs","siteName","String","merge","site","personalLine","templateBody","ok","subject","text","unresolvedFields","error"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/preview-routes.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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { CRM_COLLECTIONS } from '@aglyn/aglyn/app-utils/crm'\nimport { normalizeCrmEmailTemplate } from '@aglyn/aglyn/app-utils/crm-email-templates'\nimport { visibleToHost } from '@aglyn/aglyn/app-utils/scope-tokens'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { firstEmailStepIndex } from '../engine/sequence-validation'\nimport { leadAsContact } from '../enrollment/enroll-people'\nimport type { OutreachPreviewResponse } from '../model/outreach-api'\nimport {\n OUTREACH_SAMPLE_PERSON,\n outreachSampleMergeContext,\n previewOutreachStep,\n} from '../model/step-preview'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_READS_PEOPLE } from './enroll-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * ONE EMAIL OF A SAVED SEQUENCE, AS A PERSON WOULD GET IT (AGL-2980):\n * `outreach/preview`.\n *\n * Written from what is stored — the sequence, the CRM template a step names,\n * the mailbox it sends as, the organization's footer — by the engine's own\n * composer (`previewOutreachStep`), for a real contact with the personal\n * line the rep is writing, or for a sample person. The enroll dialog shows\n * it beside each person before Confirm, so the rep reads the sentence they\n * wrote in the email it goes into.\n *\n * A real contact is read from the CRM, so naming one asks for `data.manage`\n * as enrolling does; the sample person asks for nothing more.\n */\n\nexport function createOutreachPreviewRoute(deps: OutreachRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n const contactId = body['contactId'] === undefined ? null : readOutreachDocumentId(body['contactId'])\n if (body['contactId'] !== undefined && !contactId) {\n return outreachRefusal(400, 'invalid-request', 'Name the contact to preview for.')\n }\n // A lead the sequence's site holds (AGL-3234), previewed as the contact it would be.\n const leadId = body['leadId'] === undefined ? null : readOutreachDocumentId(body['leadId'])\n if (body['leadId'] !== undefined && !leadId) {\n return outreachRefusal(400, 'invalid-request', 'Name the lead to preview for.')\n }\n const caller = await outreachRouteGate(\n request,\n body['orgId'],\n deps.gate,\n contactId || leadId ? [OUTREACH_READS_PEOPLE] : [],\n )\n if (caller instanceof Response) return caller\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to preview.')\n const firestore = deps.firestore()\n const org = firestore.collection('orgs').doc(caller.orgId)\n const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequenceId).get()\n const sequence = readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n\n const requested = body['stepIndex']\n const stepIndex = typeof requested === 'number' ? requested : firstEmailStepIndex(sequence.steps)\n const step = sequence.steps[stepIndex]\n if (step?.kind !== 'email') {\n return outreachRefusal(400, 'invalid-request', 'That step does not send an email.')\n }\n\n const [orgSettings, mailboxSnapshot, host, template, contact, lead] = await Promise.all([\n readOutreachComplianceSettingsDoc(firestore, caller.orgId),\n sequence.mailboxId ? outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get() : null,\n sequence.hostId ? firestore.collection('hosts').doc(sequence.hostId).get() : null,\n step.templateId ? org.collection(CRM_COLLECTIONS.emailTemplates).doc(step.templateId).get() : null,\n contactId ? org.collection('contacts').doc(contactId).get() : null,\n // One org row (AGL-3275); the site is still required because a lead\n // preview is rendered in a site's context.\n leadId && sequence.hostId ? org.collection('leads').doc(leadId).get() : null,\n ])\n const leadData = lead?.exists ? (lead.data() as Record<string, unknown>) : null\n if (leadId && !leadData) {\n return outreachRefusal(404, 'contact-not-found', \"That lead isn't in this sequence's site's CRM.\")\n }\n const contactGroupId = consentGroupForHost(caller.org, sequence.hostId).groupId\n const contactData = contact?.exists\n ? (contact.data() as Record<string, unknown>)\n : leadData\n ? leadAsContact(leadData, contactGroupId)\n : null\n if (\n contactId &&\n (!contactData || !visibleToHost(contactData['visibleTo'] as string[] | undefined, sequence.hostId))\n ) {\n return outreachRefusal(404, 'contact-not-found', \"That contact isn't in this sequence's site's CRM.\")\n }\n const mailbox = readStoredOutreachMailbox(\n sequence.mailboxId,\n mailboxSnapshot?.exists ? mailboxSnapshot.data() : undefined,\n )\n const sender = mailbox ? { name: mailbox.displayName, email: mailbox.sendAs || mailbox.email } : null\n const siteName = host?.exists ? String(host.get('name') ?? '') : ''\n const merge = contactData\n ? {\n contact: contactData,\n contactGroupId,\n lead: leadData,\n sender,\n site: { name: siteName },\n }\n : outreachSampleMergeContext({ sender, siteName })\n const personalLine =\n typeof body['personalLine'] === 'string'\n ? body['personalLine']\n : contactData\n ? ''\n : OUTREACH_SAMPLE_PERSON.personalLine\n const result = previewOutreachStep({\n steps: sequence.steps,\n stepIndex,\n orgSettings,\n merge,\n email: contactData ? (normalizeContactEmail(contactData['email']) ?? '') : OUTREACH_SAMPLE_PERSON.email,\n personalLine,\n templateBody: template?.exists\n ? normalizeCrmEmailTemplate(template.data() as Record<string, unknown>).body\n : null,\n })\n return outreachOk({\n ok: true,\n stepIndex,\n subject: result.email?.subject ?? '',\n text: result.email?.text ?? '',\n unresolvedFields: result.unresolvedFields,\n error: result.error,\n } satisfies OutreachPreviewResponse)\n }\n}\n"],"names":["consentGroupForHost","normalizeContactEmail","CRM_COLLECTIONS","normalizeCrmEmailTemplate","visibleToHost","firstEmailStepIndex","leadAsContact","OUTREACH_SAMPLE_PERSON","outreachSampleMergeContext","previewOutreachStep","readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_READS_PEOPLE","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","createOutreachPreviewRoute","deps","request","host","result","method","body","contactId","undefined","leadId","caller","gate","Response","sequenceId","firestore","org","collection","doc","orgId","snapshot","get","sequence","exists","data","requested","stepIndex","steps","step","kind","orgSettings","mailboxSnapshot","template","contact","lead","Promise","all","mailboxId","hostId","templateId","emailTemplates","leadData","contactGroupId","groupId","contactData","mailbox","sender","name","displayName","email","sendAs","siteName","String","merge","site","personalLine","templateBody","ok","subject","text","unresolvedFields","error"],"mappings":"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"}
@@ -13,7 +13,8 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
16
+ */ import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
17
+ import { registerPluginApiRoute } from "@aglyn/aglyn/server";
17
18
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
18
19
  import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
19
20
  import { outreachOrgSubject } from "../mailboxes/register-mailbox-routes.js";
@@ -66,6 +67,14 @@ export function defaultOutreachRouteDeps() {
66
67
  outcome,
67
68
  atMs
68
69
  }, firebaseAdmin.app().firestore());
70
+ },
71
+ // The record system's writer (AGL-3274), resolved per call the way the
72
+ // runtime resolves it: the CRM registers at boot, and a request that
73
+ // arrives before it did files nothing rather than caching a `null`.
74
+ timeline: ()=>{
75
+ var _ref;
76
+ var _pluginRecordTimelineWriter;
77
+ return (_ref = (_pluginRecordTimelineWriter = pluginRecordTimelineWriter()) == null ? void 0 : _pluginRecordTimelineWriter.writer) != null ? _ref : null;
69
78
  }
70
79
  };
71
80
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n}\n"],"names":["registerPluginApiRoute","firebaseAdmin","OUTREACH_API_ROUTES","outreachOrgSubject","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","registerOutreachRoutes","deps","sequences","enroll","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","remove","enrollPreview","preview","confirm","enrollmentsAction","doNotContactDomains"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.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 { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","registerPluginApiRoute","firebaseAdmin","OUTREACH_API_ROUTES","outreachOrgSubject","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","registerOutreachRoutes","deps","sequences","enroll","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","remove","enrollPreview","preview","confirm","enrollmentsAction","doNotContactDomains"],"mappings":"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"}
@@ -69,16 +69,18 @@ export type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_
69
69
  * 3. every open enrollment of the address in the organization: the one it
70
70
  * came from with the reason given, the others as `do_not_contact`;
71
71
  * 4. the lead and the contact that carry the address (AGL-3245), so the
72
- * record a person reads says what the lists hold.
72
+ * record a person reads says what the lists hold;
73
+ * 5. the person's Activity (AGL-3274): "<sequence> stopped: they opted
74
+ * out", once per enrollment, under the last email it sent.
73
75
  *
74
76
  * Each is idempotent, so a second request — the link used twice, a reply
75
77
  * read by two runs — changes nothing.
76
78
  */
77
- export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>, input: {
79
+ export declare function recordOutreachOptOut(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState' | 'timeline'>, input: {
78
80
  orgId: string;
79
81
  email: string;
80
82
  source: OutreachOptOutSource;
81
- enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null;
83
+ enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId' | 'target' | 'contactId' | 'leadId'> | null;
82
84
  hostIds: readonly string[];
83
85
  detail: string | null;
84
86
  nowMs: number;
@@ -15,10 +15,12 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
15
15
  * See the License for the specific language governing permissions and
16
16
  * limitations under the License.
17
17
  */ import { normalizeContactEmail } from "@aglyn/aglyn/app-utils/contacts";
18
+ import { outreachStoppedEntry } from "../engine/enrollment-activity.js";
18
19
  import { applyOutreachEnrollmentEvent } from "../engine/enrollment-state.js";
19
20
  import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
20
21
  import { addOutreachDoNotContact } from "../storage/do-not-contact-store.js";
21
- import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
22
+ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachSequence } from "../storage/outreach-records.js";
23
+ import { fileOutreachNote } from "./timeline.js";
22
24
  /**
23
25
  * Applies one event to one enrollment, with any fields the caller writes
24
26
  * beside it — its handled message ids, a postponed due time. A transition
@@ -85,7 +87,9 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
85
87
  * 3. every open enrollment of the address in the organization: the one it
86
88
  * came from with the reason given, the others as `do_not_contact`;
87
89
  * 4. the lead and the contact that carry the address (AGL-3245), so the
88
- * record a person reads says what the lists hold.
90
+ * record a person reads says what the lists hold;
91
+ * 5. the person's Activity (AGL-3274): "<sequence> stopped: they opted
92
+ * out", once per enrollment, under the last email it sent.
89
93
  *
90
94
  * Each is idempotent, so a second request — the link used twice, a reply
91
95
  * read by two runs — changes nothing.
@@ -141,7 +145,27 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
141
145
  },
142
146
  nowMs: input.nowMs
143
147
  });
144
- if (own.changed) stopped += 1;
148
+ if (own.changed) {
149
+ var _ref3;
150
+ stopped += 1;
151
+ // 5. the person's record says the sequence ended (AGL-3274), once,
152
+ // after the status moved — so the link used twice files once.
153
+ const sequenceSnapshot = await outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.enrollment.sequenceId).get();
154
+ const sequence = readStoredOutreachSequence(input.enrollment.sequenceId, sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined);
155
+ const entry = outreachStoppedEntry({
156
+ enrollmentId: input.enrollment.id,
157
+ sequenceName: (_ref3 = sequence == null ? void 0 : sequence.name) != null ? _ref3 : '',
158
+ reason: 'opted_out'
159
+ });
160
+ await fileOutreachNote(deps, {
161
+ orgId: input.orgId,
162
+ hostId: input.enrollment.hostId,
163
+ link: outreachEnrollmentLink(input.enrollment),
164
+ dedupeKey: entry.dedupeKey,
165
+ body: entry.body,
166
+ atMs: input.nowMs
167
+ });
168
+ }
145
169
  }
146
170
  stopped += await optOutOtherOutreachEnrollments(firestore, {
147
171
  orgId: input.orgId,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/enrollment-events.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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachDoNotContactReason, OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT THE RUNTIME DOES TO ENROLLMENTS, AND TO THE LISTS BESIDE THEM\n * (AGL-2981).\n *\n * Every status change is the engine's (`applyOutreachEnrollmentEvent`),\n * written in a transaction over the one enrollment so a member's pause and a\n * run's step completion never interleave. The runtime's events name no\n * member: `stoppedByUid` stays `null`.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The enrollment after an event, and whether the event changed it. */\nexport interface OutreachEventOutcome {\n enrollment: OutreachEnrollment | null\n changed: boolean\n}\n\n/**\n * Applies one event to one enrollment, with any fields the caller writes\n * beside it — its handled message ids, a postponed due time. A transition\n * the engine refuses writes only the extra fields.\n */\nexport async function applyOutreachEvent(\n firestore: Firestore,\n input: {\n orgId: string\n enrollmentId: string\n event: OutreachEnrollmentEvent | null\n extra?: (enrollment: OutreachEnrollment) => Record<string, unknown> | null\n nowMs: number\n },\n): Promise<OutreachEventOutcome> {\n const ref = outreachOrgCollection(firestore, input.orgId, 'enrollments').doc(input.enrollmentId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(input.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { enrollment: null, changed: false }\n const patch = input.event ? applyOutreachEnrollmentEvent(enrollment, input.event).patch : null\n const extra = input.extra?.(enrollment) ?? null\n if (!patch && !extra) return { enrollment, changed: false }\n const written = { ...(patch ?? {}), ...(extra ?? {}), updatedAtMs: input.nowMs }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, changed: patch !== null }\n })\n}\n\n/**\n * Opts every OTHER open enrollment of an address in the organization out,\n * as `do_not_contact`: the person asked not to be emailed, so no sequence\n * is next. Answers how many moved.\n */\nexport async function optOutOtherOutreachEnrollments(\n firestore: Firestore,\n input: { orgId: string; email: string; exceptEnrollmentId: string | null; nowMs: number; detail: string },\n): Promise<number> {\n const found = await outreachOrgCollection(firestore, input.orgId, 'enrollments')\n .where('email', '==', input.email)\n .get()\n let moved = 0\n for (const doc of found.docs) {\n if (doc.id === input.exceptEnrollmentId) continue\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(doc.get('status'))) continue\n const outcome = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: doc.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: 'do_not_contact', detail: input.detail },\n nowMs: input.nowMs,\n })\n if (outcome.changed) moved += 1\n }\n return moved\n}\n\n/** Where an opt-out came from. */\nexport type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_reply' | 'unsubscribe'>\n\n/**\n * A person asked not to be emailed — by the unsubscribe link, the `mailto:`\n * or a reply — and every list that has to remember it does:\n *\n * 1. the organization's do-not-contact list, which every sequence checks\n * before every send (its first entry for the address stands);\n * 2. the `sales` topic of each site named, which the site's own sales\n * email reads too;\n * 3. every open enrollment of the address in the organization: the one it\n * came from with the reason given, the others as `do_not_contact`;\n * 4. the lead and the contact that carry the address (AGL-3245), so the\n * record a person reads says what the lists hold.\n *\n * Each is idempotent, so a second request — the link used twice, a reply\n * read by two runs — changes nothing.\n */\nexport async function recordOutreachOptOut(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId'> | null\n hostIds: readonly string[]\n detail: string | null\n nowMs: number\n /** The reply called the email spam (AGL-3245): the record says so. */\n complaint?: boolean\n },\n): Promise<{ stopped: number }> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { stopped: 0 }\n const firestore = deps.firestore()\n await addOutreachDoNotContact(firestore, {\n orgId: input.orgId,\n email,\n reason: input.source,\n source: 'runtime',\n nowMs: input.nowMs,\n enrollmentId: input.enrollment?.id ?? null,\n sequenceId: input.enrollment?.sequenceId ?? null,\n detail: input.detail,\n })\n for (const hostId of new Set(input.hostIds.filter(Boolean))) {\n await deps.optOutOfSalesTopic({ hostId, email })\n }\n // 4. the record the person is (AGL-3245): the link and the header say\n // \"unsubscribed\"; a reply that asked is the runtime's own list entry,\n // and one that called the email spam is a complaint.\n await deps.stampRecordEmailState({\n orgId: input.orgId,\n email,\n state: {\n status: input.complaint ? 'complained' : input.source === 'unsubscribe' ? 'unsubscribed' : 'do_not_contact',\n atMs: input.nowMs,\n source: 'sequence',\n detail: input.detail,\n ...(input.enrollment?.id ? { enrollmentId: input.enrollment.id } : {}),\n },\n })\n let stopped = 0\n if (input.enrollment) {\n const own = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: input.enrollment.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: input.source, detail: input.detail },\n nowMs: input.nowMs,\n })\n if (own.changed) stopped += 1\n }\n stopped += await optOutOtherOutreachEnrollments(firestore, {\n orgId: input.orgId,\n email,\n exceptEnrollmentId: input.enrollment?.id ?? null,\n nowMs: input.nowMs,\n detail:\n input.source === 'unsubscribe'\n ? 'Unsubscribed from another sequence.'\n : 'Asked not to be emailed in a reply to another sequence.',\n })\n return { stopped }\n}\n"],"names":["normalizeContactEmail","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","applyOutreachEvent","firestore","input","ref","orgId","doc","enrollmentId","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","changed","patch","event","extra","written","updatedAtMs","nowMs","update","optOutOtherOutreachEnrollments","found","where","email","moved","docs","id","exceptEnrollmentId","includes","outcome","type","atMs","reason","detail","recordOutreachOptOut","deps","stopped","source","sequenceId","hostId","Set","hostIds","filter","Boolean","optOutOfSalesTopic","stampRecordEmailState","state","status","complaint","own"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/enrollment-events.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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport { outreachStoppedEntry } from '../engine/enrollment-activity'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachDoNotContactReason, OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { fileOutreachNote } from './timeline'\n\n/**\n * WHAT THE RUNTIME DOES TO ENROLLMENTS, AND TO THE LISTS BESIDE THEM\n * (AGL-2981).\n *\n * Every status change is the engine's (`applyOutreachEnrollmentEvent`),\n * written in a transaction over the one enrollment so a member's pause and a\n * run's step completion never interleave. The runtime's events name no\n * member: `stoppedByUid` stays `null`.\n */\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** The enrollment after an event, and whether the event changed it. */\nexport interface OutreachEventOutcome {\n enrollment: OutreachEnrollment | null\n changed: boolean\n}\n\n/**\n * Applies one event to one enrollment, with any fields the caller writes\n * beside it — its handled message ids, a postponed due time. A transition\n * the engine refuses writes only the extra fields.\n */\nexport async function applyOutreachEvent(\n firestore: Firestore,\n input: {\n orgId: string\n enrollmentId: string\n event: OutreachEnrollmentEvent | null\n extra?: (enrollment: OutreachEnrollment) => Record<string, unknown> | null\n nowMs: number\n },\n): Promise<OutreachEventOutcome> {\n const ref = outreachOrgCollection(firestore, input.orgId, 'enrollments').doc(input.enrollmentId)\n return firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(input.enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return { enrollment: null, changed: false }\n const patch = input.event ? applyOutreachEnrollmentEvent(enrollment, input.event).patch : null\n const extra = input.extra?.(enrollment) ?? null\n if (!patch && !extra) return { enrollment, changed: false }\n const written = { ...(patch ?? {}), ...(extra ?? {}), updatedAtMs: input.nowMs }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, changed: patch !== null }\n })\n}\n\n/**\n * Opts every OTHER open enrollment of an address in the organization out,\n * as `do_not_contact`: the person asked not to be emailed, so no sequence\n * is next. Answers how many moved.\n */\nexport async function optOutOtherOutreachEnrollments(\n firestore: Firestore,\n input: { orgId: string; email: string; exceptEnrollmentId: string | null; nowMs: number; detail: string },\n): Promise<number> {\n const found = await outreachOrgCollection(firestore, input.orgId, 'enrollments')\n .where('email', '==', input.email)\n .get()\n let moved = 0\n for (const doc of found.docs) {\n if (doc.id === input.exceptEnrollmentId) continue\n if (!OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(doc.get('status'))) continue\n const outcome = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: doc.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: 'do_not_contact', detail: input.detail },\n nowMs: input.nowMs,\n })\n if (outcome.changed) moved += 1\n }\n return moved\n}\n\n/** Where an opt-out came from. */\nexport type OutreachOptOutSource = Extract<OutreachDoNotContactReason, 'opt_out_reply' | 'unsubscribe'>\n\n/**\n * A person asked not to be emailed — by the unsubscribe link, the `mailto:`\n * or a reply — and every list that has to remember it does:\n *\n * 1. the organization's do-not-contact list, which every sequence checks\n * before every send (its first entry for the address stands);\n * 2. the `sales` topic of each site named, which the site's own sales\n * email reads too;\n * 3. every open enrollment of the address in the organization: the one it\n * came from with the reason given, the others as `do_not_contact`;\n * 4. the lead and the contact that carry the address (AGL-3245), so the\n * record a person reads says what the lists hold;\n * 5. the person's Activity (AGL-3274): \"<sequence> stopped: they opted\n * out\", once per enrollment, under the last email it sent.\n *\n * Each is idempotent, so a second request — the link used twice, a reply\n * read by two runs — changes nothing.\n */\nexport async function recordOutreachOptOut(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'optOutOfSalesTopic' | 'stampRecordEmailState' | 'timeline'>,\n input: {\n orgId: string\n email: string\n source: OutreachOptOutSource\n enrollment: Pick<OutreachEnrollment, 'id' | 'sequenceId' | 'hostId' | 'target' | 'contactId' | 'leadId'> | null\n hostIds: readonly string[]\n detail: string | null\n nowMs: number\n /** The reply called the email spam (AGL-3245): the record says so. */\n complaint?: boolean\n },\n): Promise<{ stopped: number }> {\n const email = normalizeContactEmail(input.email)\n if (!email) return { stopped: 0 }\n const firestore = deps.firestore()\n await addOutreachDoNotContact(firestore, {\n orgId: input.orgId,\n email,\n reason: input.source,\n source: 'runtime',\n nowMs: input.nowMs,\n enrollmentId: input.enrollment?.id ?? null,\n sequenceId: input.enrollment?.sequenceId ?? null,\n detail: input.detail,\n })\n for (const hostId of new Set(input.hostIds.filter(Boolean))) {\n await deps.optOutOfSalesTopic({ hostId, email })\n }\n // 4. the record the person is (AGL-3245): the link and the header say\n // \"unsubscribed\"; a reply that asked is the runtime's own list entry,\n // and one that called the email spam is a complaint.\n await deps.stampRecordEmailState({\n orgId: input.orgId,\n email,\n state: {\n status: input.complaint ? 'complained' : input.source === 'unsubscribe' ? 'unsubscribed' : 'do_not_contact',\n atMs: input.nowMs,\n source: 'sequence',\n detail: input.detail,\n ...(input.enrollment?.id ? { enrollmentId: input.enrollment.id } : {}),\n },\n })\n let stopped = 0\n if (input.enrollment) {\n const own = await applyOutreachEvent(firestore, {\n orgId: input.orgId,\n enrollmentId: input.enrollment.id,\n event: { type: 'opt_out', atMs: input.nowMs, reason: input.source, detail: input.detail },\n nowMs: input.nowMs,\n })\n if (own.changed) {\n stopped += 1\n // 5. the person's record says the sequence ended (AGL-3274), once,\n // after the status moved — so the link used twice files once.\n const sequenceSnapshot = await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.enrollment.sequenceId)\n .get()\n const sequence = readStoredOutreachSequence(\n input.enrollment.sequenceId,\n sequenceSnapshot.exists ? sequenceSnapshot.data() : undefined,\n )\n const entry = outreachStoppedEntry({\n enrollmentId: input.enrollment.id,\n sequenceName: sequence?.name ?? '',\n reason: 'opted_out',\n })\n await fileOutreachNote(deps, {\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n dedupeKey: entry.dedupeKey,\n body: entry.body,\n atMs: input.nowMs,\n })\n }\n }\n stopped += await optOutOtherOutreachEnrollments(firestore, {\n orgId: input.orgId,\n email,\n exceptEnrollmentId: input.enrollment?.id ?? null,\n nowMs: input.nowMs,\n detail:\n input.source === 'unsubscribe'\n ? 'Unsubscribed from another sequence.'\n : 'Asked not to be emailed in a reply to another sequence.',\n })\n return { stopped }\n}\n"],"names":["normalizeContactEmail","outreachStoppedEntry","applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachSequence","fileOutreachNote","applyOutreachEvent","firestore","input","ref","orgId","doc","enrollmentId","runTransaction","transaction","snapshot","get","enrollment","exists","data","undefined","changed","patch","event","extra","written","updatedAtMs","nowMs","update","optOutOtherOutreachEnrollments","found","where","email","moved","docs","id","exceptEnrollmentId","includes","outcome","type","atMs","reason","detail","recordOutreachOptOut","deps","stopped","source","sequenceId","hostId","Set","hostIds","filter","Boolean","optOutOfSalesTopic","stampRecordEmailState","state","status","complaint","own","sequenceSnapshot","sequence","entry","sequenceName","name","link","dedupeKey","body"],"mappings":"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"}
@@ -18,7 +18,7 @@ import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@a
18
18
  type Firestore = FirebaseFirestore.Firestore;
19
19
  /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */
20
20
  export declare function markOutreachLeadWorking(firestore: Firestore, input: {
21
- hostId: string;
21
+ orgId: string;
22
22
  leadId: string;
23
23
  }): Promise<boolean>;
24
24
  /** Every enrollment made on the lead now names the contact it became. */
@@ -41,7 +41,8 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
41
41
  /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */ export async function markOutreachLeadWorking(firestore, input) {
42
42
  try {
43
43
  var _snapshot_data;
44
- const ref = firestore.collection('hosts').doc(input.hostId).collection('leads').doc(input.leadId);
44
+ // A lead is one org row (AGL-3275), so the site no longer names it.
45
+ const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId);
45
46
  const snapshot = await ref.get();
46
47
  if (!snapshot.exists) return false;
47
48
  const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
@@ -54,7 +55,7 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
54
55
  });
55
56
  return true;
56
57
  } catch (error) {
57
- console.error('[outreach] the lead could not be marked working', input.hostId, input.leadId, error);
58
+ console.error('[outreach] the lead could not be marked working', input.orgId, input.leadId, error);
58
59
  return false;
59
60
  }
60
61
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/lead-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).\n *\n * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's\n * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead\n * statuses), never through an import of the CRM plugin:\n *\n * - a REPLY moves a lead nobody has touched from New to Working. Somebody\n * wrote back; the lead is being worked, and a queue that still showed it\n * as New would send a rep to open a conversation already open. A lead\n * already Working, closed, or converted is left as it is — a reply does\n * not reopen a verdict or move a contact's record.\n * - a CONVERSION re-points every enrollment made on the lead at the contact\n * it became: the record system tells every plugin through its\n * lead-conversion seam, and this is Sequences' share of it. The\n * enrollment keeps its id, its thread and its place in the steps, and\n * keeps naming the lead beside the contact, so both pages list it.\n *\n * Neither throws: a status that could not be moved is one a rep moves by\n * hand, and an enrollment that could not follow its lead is one the next\n * send stops as `contact_missing` and a rep re-enrolls.\n */\n\nimport { crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */\nexport async function markOutreachLeadWorking(\n firestore: Firestore,\n input: { hostId: string; leadId: string },\n): Promise<boolean> {\n try {\n const ref = firestore.collection('hosts').doc(input.hostId).collection('leads').doc(input.leadId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId'] || crmLeadStatus(lead as never) !== 'new') return false\n await ref.set({ status: 'working', updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n return true\n } catch (error) {\n console.error('[outreach] the lead could not be marked working', input.hostId, input.leadId, error)\n return false\n }\n}\n\n/** Every enrollment made on the lead now names the contact it became. */\nexport async function followOutreachLeadToContact(\n firestore: Firestore,\n input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>,\n): Promise<PluginLeadConversionReport> {\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n const rows = await enrollments.where('leadId', '==', input.leadId).get()\n const refs = rows.docs\n .filter((row) => row.get('target') !== 'contact' || String(row.get('contactId') ?? '') !== input.contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { target: 'contact', contactId: input.contactId, updatedAtMs: Date.now() })\n }\n await batch.commit()\n }\n return { enrollments: refs.length }\n}\n"],"names":["crmLeadStatus","FieldValue","outreachOrgCollection","BATCH_LIMIT","markOutreachLeadWorking","firestore","input","snapshot","ref","collection","doc","hostId","leadId","get","exists","lead","data","set","status","updatedAt","serverTimestamp","merge","error","console","followOutreachLeadToContact","enrollments","orgId","rows","where","refs","docs","filter","row","String","contactId","map","start","length","batch","slice","update","target","updatedAtMs","Date","now","commit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,SAASA,aAAa,QAAQ,6BAA4B;AAK1D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,qFAAqF,GACrF,OAAO,eAAeC,wBACpBC,SAAoB,EACpBC,KAAyC;IAEzC,IAAI;YAIWC;QAHb,MAAMC,MAAMH,UAAUI,UAAU,CAAC,SAASC,GAAG,CAACJ,MAAMK,MAAM,EAAEF,UAAU,CAAC,SAASC,GAAG,CAACJ,MAAMM,MAAM;QAChG,MAAML,WAAW,MAAMC,IAAIK,GAAG;QAC9B,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAOR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QACjC,IAAIQ,IAAI,CAAC,qBAAqB,IAAIf,cAAce,UAAmB,OAAO,OAAO;QACjF,MAAMP,IAAIS,GAAG,CAAC;YAAEC,QAAQ;YAAWC,WAAWlB,WAAWmB,eAAe;QAAG,GAAG;YAAEC,OAAO;QAAK;QAC5F,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,mDAAmDhB,MAAMK,MAAM,EAAEL,MAAMM,MAAM,EAAEU;QAC7F,OAAO;IACT;AACF;AAEA,uEAAuE,GACvE,OAAO,eAAeE,4BACpBnB,SAAoB,EACpBC,KAA0E;IAE1E,MAAMmB,cAAcvB,sBAAsBG,WAAWC,MAAMoB,KAAK,EAAE;IAClE,MAAMC,OAAO,MAAMF,YAAYG,KAAK,CAAC,UAAU,MAAMtB,MAAMM,MAAM,EAAEC,GAAG;IACtE,MAAMgB,OAAOF,KAAKG,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAkDA;eAA1CA,IAAInB,GAAG,CAAC,cAAc,aAAaoB,QAAOD,WAAAA,IAAInB,GAAG,CAAC,wBAARmB,WAAwB,QAAQ1B,MAAM4B,SAAS;OACzGC,GAAG,CAAC,CAACH,MAAQA,IAAIxB,GAAG;IACvB,IAAK,IAAI4B,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAASjC,YAAa;QAC7D,MAAMmC,QAAQjC,UAAUiC,KAAK;QAC7B,KAAK,MAAM9B,OAAOqB,KAAKU,KAAK,CAACH,OAAOA,QAAQjC,aAAc;YACxDmC,MAAME,MAAM,CAAChC,KAAK;gBAAEiC,QAAQ;gBAAWP,WAAW5B,MAAM4B,SAAS;gBAAEQ,aAAaC,KAAKC,GAAG;YAAG;QAC7F;QACA,MAAMN,MAAMO,MAAM;IACpB;IACA,OAAO;QAAEpB,aAAaI,KAAKQ,MAAM;IAAC;AACpC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/lead-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).\n *\n * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's\n * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead\n * statuses), never through an import of the CRM plugin:\n *\n * - a REPLY moves a lead nobody has touched from New to Working. Somebody\n * wrote back; the lead is being worked, and a queue that still showed it\n * as New would send a rep to open a conversation already open. A lead\n * already Working, closed, or converted is left as it is — a reply does\n * not reopen a verdict or move a contact's record.\n * - a CONVERSION re-points every enrollment made on the lead at the contact\n * it became: the record system tells every plugin through its\n * lead-conversion seam, and this is Sequences' share of it. The\n * enrollment keeps its id, its thread and its place in the steps, and\n * keeps naming the lead beside the contact, so both pages list it.\n *\n * Neither throws: a status that could not be moved is one a rep moves by\n * hand, and an enrollment that could not follow its lead is one the next\n * send stops as `contact_missing` and a rep re-enrolls.\n */\n\nimport { crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */\nexport async function markOutreachLeadWorking(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n try {\n // A lead is one org row (AGL-3275), so the site no longer names it.\n const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId'] || crmLeadStatus(lead as never) !== 'new') return false\n await ref.set({ status: 'working', updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n return true\n } catch (error) {\n console.error('[outreach] the lead could not be marked working', input.orgId, input.leadId, error)\n return false\n }\n}\n\n/** Every enrollment made on the lead now names the contact it became. */\nexport async function followOutreachLeadToContact(\n firestore: Firestore,\n input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>,\n): Promise<PluginLeadConversionReport> {\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n const rows = await enrollments.where('leadId', '==', input.leadId).get()\n const refs = rows.docs\n .filter((row) => row.get('target') !== 'contact' || String(row.get('contactId') ?? '') !== input.contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { target: 'contact', contactId: input.contactId, updatedAtMs: Date.now() })\n }\n await batch.commit()\n }\n return { enrollments: refs.length }\n}\n"],"names":["crmLeadStatus","FieldValue","outreachOrgCollection","BATCH_LIMIT","markOutreachLeadWorking","firestore","input","snapshot","ref","collection","doc","orgId","leadId","get","exists","lead","data","set","status","updatedAt","serverTimestamp","merge","error","console","followOutreachLeadToContact","enrollments","rows","where","refs","docs","filter","row","String","contactId","map","start","length","batch","slice","update","target","updatedAtMs","Date","now","commit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,SAASA,aAAa,QAAQ,6BAA4B;AAK1D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,qFAAqF,GACrF,OAAO,eAAeC,wBACpBC,SAAoB,EACpBC,KAAwC;IAExC,IAAI;YAKWC;QAJb,oEAAoE;QACpE,MAAMC,MAAMH,UAAUI,UAAU,CAAC,QAAQC,GAAG,CAACJ,MAAMK,KAAK,EAAEF,UAAU,CAAC,SAASC,GAAG,CAACJ,MAAMM,MAAM;QAC9F,MAAML,WAAW,MAAMC,IAAIK,GAAG;QAC9B,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAOR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QACjC,IAAIQ,IAAI,CAAC,qBAAqB,IAAIf,cAAce,UAAmB,OAAO,OAAO;QACjF,MAAMP,IAAIS,GAAG,CAAC;YAAEC,QAAQ;YAAWC,WAAWlB,WAAWmB,eAAe;QAAG,GAAG;YAAEC,OAAO;QAAK;QAC5F,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,mDAAmDhB,MAAMK,KAAK,EAAEL,MAAMM,MAAM,EAAEU;QAC5F,OAAO;IACT;AACF;AAEA,uEAAuE,GACvE,OAAO,eAAeE,4BACpBnB,SAAoB,EACpBC,KAA0E;IAE1E,MAAMmB,cAAcvB,sBAAsBG,WAAWC,MAAMK,KAAK,EAAE;IAClE,MAAMe,OAAO,MAAMD,YAAYE,KAAK,CAAC,UAAU,MAAMrB,MAAMM,MAAM,EAAEC,GAAG;IACtE,MAAMe,OAAOF,KAAKG,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAkDA;eAA1CA,IAAIlB,GAAG,CAAC,cAAc,aAAamB,QAAOD,WAAAA,IAAIlB,GAAG,CAAC,wBAARkB,WAAwB,QAAQzB,MAAM2B,SAAS;OACzGC,GAAG,CAAC,CAACH,MAAQA,IAAIvB,GAAG;IACvB,IAAK,IAAI2B,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAAShC,YAAa;QAC7D,MAAMkC,QAAQhC,UAAUgC,KAAK;QAC7B,KAAK,MAAM7B,OAAOoB,KAAKU,KAAK,CAACH,OAAOA,QAAQhC,aAAc;YACxDkC,MAAME,MAAM,CAAC/B,KAAK;gBAAEgC,QAAQ;gBAAWP,WAAW3B,MAAM2B,SAAS;gBAAEQ,aAAaC,KAAKC,GAAG;YAAG;QAC7F;QACA,MAAMN,MAAMO,MAAM;IACpB;IACA,OAAO;QAAEnB,aAAaG,KAAKQ,MAAM;IAAC;AACpC"}