@aglyn/plugins-outreach 1.0.0-beta.152 → 1.0.0-beta.153

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.
Files changed (51) hide show
  1. package/README.md +5 -1
  2. package/package.json +12 -12
  3. package/src/lib/components/enroll-dialog.js +142 -32
  4. package/src/lib/components/enroll-dialog.js.map +1 -1
  5. package/src/lib/components/enrollments-table.js +37 -8
  6. package/src/lib/components/enrollments-table.js.map +1 -1
  7. package/src/lib/components/mailbox-card.d.ts +1 -0
  8. package/src/lib/components/mailbox-card.js +25 -5
  9. package/src/lib/components/mailbox-card.js.map +1 -1
  10. package/src/lib/components/use-outreach-crm.d.ts +20 -1
  11. package/src/lib/components/use-outreach-crm.js +78 -4
  12. package/src/lib/components/use-outreach-crm.js.map +1 -1
  13. package/src/lib/declarations.console-server.js +14 -0
  14. package/src/lib/declarations.console-server.js.map +1 -1
  15. package/src/lib/engine/enrollment-state.d.ts +6 -1
  16. package/src/lib/engine/enrollment-state.js +3 -1
  17. package/src/lib/engine/enrollment-state.js.map +1 -1
  18. package/src/lib/enrollment/enroll-people.d.ts +52 -7
  19. package/src/lib/enrollment/enroll-people.js +109 -12
  20. package/src/lib/enrollment/enroll-people.js.map +1 -1
  21. package/src/lib/enrollment/gate-lookups.d.ts +12 -3
  22. package/src/lib/enrollment/gate-lookups.js +17 -14
  23. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  24. package/src/lib/mailboxes/mailbox-api.d.ts +6 -0
  25. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  26. package/src/lib/mailboxes/mailbox-routes.js +11 -0
  27. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  28. package/src/lib/model/outreach-api.d.ts +39 -8
  29. package/src/lib/model/outreach-api.js.map +1 -1
  30. package/src/lib/model/outreach.types.d.ts +28 -3
  31. package/src/lib/model/outreach.types.js +9 -0
  32. package/src/lib/model/outreach.types.js.map +1 -1
  33. package/src/lib/model/stored-records.js +7 -0
  34. package/src/lib/model/stored-records.js.map +1 -1
  35. package/src/lib/routes/enroll-routes.js +112 -39
  36. package/src/lib/routes/enroll-routes.js.map +1 -1
  37. package/src/lib/routes/preview-routes.js +18 -5
  38. package/src/lib/routes/preview-routes.js.map +1 -1
  39. package/src/lib/runtime/lead-records.d.ts +26 -0
  40. package/src/lib/runtime/lead-records.js +84 -0
  41. package/src/lib/runtime/lead-records.js.map +1 -0
  42. package/src/lib/runtime/send-job.js +30 -12
  43. package/src/lib/runtime/send-job.js.map +1 -1
  44. package/src/lib/runtime/sync-job.js +11 -3
  45. package/src/lib/runtime/sync-job.js.map +1 -1
  46. package/src/lib/runtime/timeline.d.ts +6 -4
  47. package/src/lib/runtime/timeline.js +3 -7
  48. package/src/lib/runtime/timeline.js.map +1 -1
  49. package/src/lib/storage/outreach-records.d.ts +21 -2
  50. package/src/lib/storage/outreach-records.js +31 -2
  51. package/src/lib/storage/outreach-records.js.map +1 -1
@@ -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 { consentGroupForHost } from '@aglyn/aglyn/app-utils/consent-groups'\nimport {\n CRM_COLLECTIONS,\n crmViewIsListed,\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 { 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} 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 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/** The candidates, the enrollments they already have here, and the gates' lookups. */\nasync function readPeople(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n context: EnrollContext,\n contactIds: readonly string[],\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 contactIds,\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.contactId))),\n )\n : []\n const enrolledHere = new Set(\n candidates.filter((_candidate, index) => existing[index]?.exists).map((candidate) => candidate.contactId),\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) => ({ contactId: candidate.contactId, email: String(candidate.email) })),\n })\n : new Map<string, OutreachGateLookups>()\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\nexport function createOutreachEnrollRoutes(deps: OutreachEnrollRouteDeps): OutreachEnrollRoutes {\n /**\n * The contacts a source names, the batch limit applied: every contact a\n * search picked, or the people a saved Contacts view selects at the\n * sequence's site, found by address.\n */\n async function sourceContactIds(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequence: OutreachSequence,\n source: Record<string, unknown>,\n ): Promise<Response | { contactIds: string[]; total: number; truncated: boolean }> {\n if (source['kind'] === 'contacts') {\n const ids = uniqueIds(source['contactIds'])\n if (!ids.length) return outreachRefusal(400, 'invalid-request', 'Pick the people to enroll.')\n return {\n contactIds: ids.slice(0, OUTREACH_ENROLL_BATCH_MAX),\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'] !== 'contacts') {\n return outreachRefusal(400, 'view-unsupported', 'Enroll from a saved view of Contacts.')\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 contactIds: [...new Set(found.filter((snapshot) => snapshot).map((snapshot) => String(snapshot?.id)))],\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 sourceContactIds(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.contactIds,\n )\n const people = candidates.map((candidate) =>\n previewOutreachPerson(\n {\n candidate,\n lookups: lookups.get(candidate.contactId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.contactId) },\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 const requested = new Map<string, { personalLine: string; attestations: unknown[] }>()\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 if (!contactId || requested.has(contactId)) continue\n requested.set(contactId, {\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.keys()],\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 supplied = requested.get(candidate.contactId) ?? { personalLine: '', attestations: [] }\n const decision = decideOutreachEnrollment(\n {\n candidate,\n lookups: lookups.get(candidate.contactId) ?? UNCHECKED,\n settings: context.settings,\n contactGroupId: context.contactGroupId,\n },\n { siteName: context.siteName, alreadyInSequence: enrolledHere.has(candidate.contactId) },\n { ...supplied, uid: caller.uid, nowMs },\n )\n if (!decision.allowed || !decision.email) {\n return { contactId: candidate.contactId, email: candidate.email, outcome: 'blocked', blocks: decision.blocks }\n }\n const id = outreachEnrollmentId(sequence.id, candidate.contactId)\n const enrollment = buildOutreachEnrollment({\n id,\n sequence,\n mailbox,\n contactId: candidate.contactId,\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 contactId: candidate.contactId,\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 return { contactId: candidate.contactId, 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":["consentGroupForHost","CRM_COLLECTIONS","crmViewIsListed","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","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","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","contactIds","candidates","enrollments","existing","length","getAll","map","candidate","id","contactId","enrolledHere","Set","filter","_candidate","index","askable","contact","visible","email","lookups","people","Map","uniqueIds","values","Array","isArray","createOutreachEnrollRoutes","deps","sourceContactIds","source","ids","slice","total","truncated","viewId","view","views","shared","ownerUid","uid","unsupported","clause","label","field","join","emails","complete","crmViewEmails","ordered","sort","contacts","found","preview","request","method","body","gate","Response","named","alreadyInSequence","has","ok","confirm","requested","entry","person","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","nowMs","now","enrolledAtMs","random","keys","results","supplied","decision","allowed","outcome","blocks","enrollment","contactName","name","cold","enrolledByUid","create","error","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 { 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 { 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 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 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 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":["consentGroupForHost","CRM_COLLECTIONS","crmLeadStatus","crmViewIsListed","isCrmLeadOpen","normalizeCrmViewFilters","dynamicListDimensionsForCrmView","findContactByEmail","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","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","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","ref","set","personalLine","attestations","size","mailboxSnapshot","mailboxId","mailbox","nowMs","now","enrolledAtMs","random","results","target","supplied","decision","allowed","outcome","blocks","enrollment","contactName","name","cold","enrolledByUid","create","error","code","reason","enrollmentId","enrolled","result","logOrgActivity","type"],"mappings":"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"}
@@ -19,6 +19,7 @@ import { CRM_COLLECTIONS } from "@aglyn/aglyn/app-utils/crm";
19
19
  import { normalizeCrmEmailTemplate } from "@aglyn/aglyn/app-utils/crm-email-templates";
20
20
  import { visibleToHost } from "@aglyn/aglyn/app-utils/scope-tokens";
21
21
  import { firstEmailStepIndex } from "../engine/sequence-validation.js";
22
+ import { leadAsContact } from "../enrollment/enroll-people.js";
22
23
  import { OUTREACH_SAMPLE_PERSON, outreachSampleMergeContext, previewOutreachStep } from "../model/step-preview.js";
23
24
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
24
25
  import { outreachOrgCollection, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
@@ -48,7 +49,12 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
48
49
  if (body['contactId'] !== undefined && !contactId) {
49
50
  return outreachRefusal(400, 'invalid-request', 'Name the contact to preview for.');
50
51
  }
51
- const caller = await outreachRouteGate(request, body['orgId'], deps.gate, contactId ? [
52
+ // A lead the sequence's site holds (AGL-3234), previewed as the contact it would be.
53
+ const leadId = body['leadId'] === undefined ? null : readOutreachDocumentId(body['leadId']);
54
+ if (body['leadId'] !== undefined && !leadId) {
55
+ return outreachRefusal(400, 'invalid-request', 'Name the lead to preview for.');
56
+ }
57
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate, contactId || leadId ? [
52
58
  OUTREACH_READS_PEOPLE
53
59
  ] : []);
54
60
  if (caller instanceof Response) return caller;
@@ -65,14 +71,20 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
65
71
  if ((step == null ? void 0 : step.kind) !== 'email') {
66
72
  return outreachRefusal(400, 'invalid-request', 'That step does not send an email.');
67
73
  }
68
- const [orgSettings, mailboxSnapshot, host, template, contact] = await Promise.all([
74
+ const [orgSettings, mailboxSnapshot, host, template, contact, lead] = await Promise.all([
69
75
  readOutreachComplianceSettingsDoc(firestore, caller.orgId),
70
76
  sequence.mailboxId ? outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(sequence.mailboxId).get() : null,
71
77
  sequence.hostId ? firestore.collection('hosts').doc(sequence.hostId).get() : null,
72
78
  step.templateId ? org.collection(CRM_COLLECTIONS.emailTemplates).doc(step.templateId).get() : null,
73
- contactId ? org.collection('contacts').doc(contactId).get() : null
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
74
81
  ]);
75
- const contactData = (contact == null ? void 0 : contact.exists) ? contact.data() : null;
82
+ const leadData = (lead == null ? void 0 : lead.exists) ? lead.data() : null;
83
+ if (leadId && !leadData) {
84
+ return outreachRefusal(404, 'contact-not-found', "That lead isn't in this sequence's site's CRM.");
85
+ }
86
+ const contactGroupId = consentGroupForHost(caller.org, sequence.hostId).groupId;
87
+ const contactData = (contact == null ? void 0 : contact.exists) ? contact.data() : leadData ? leadAsContact(leadData, contactGroupId) : null;
76
88
  if (contactId && (!contactData || !visibleToHost(contactData['visibleTo'], sequence.hostId))) {
77
89
  return outreachRefusal(404, 'contact-not-found', "That contact isn't in this sequence's site's CRM.");
78
90
  }
@@ -84,7 +96,8 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
84
96
  const siteName = (host == null ? void 0 : host.exists) ? String((_host_get = host.get('name')) != null ? _host_get : '') : '';
85
97
  const merge = contactData ? {
86
98
  contact: contactData,
87
- contactGroupId: consentGroupForHost(caller.org, sequence.hostId).groupId,
99
+ contactGroupId,
100
+ lead: leadData,
88
101
  sender,
89
102
  site: {
90
103
  name: siteName
@@ -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 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 const caller = await outreachRouteGate(\n request,\n body['orgId'],\n deps.gate,\n contactId ? [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] = 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 ])\n const contactData = contact?.exists ? (contact.data() as Record<string, unknown>) : 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: consentGroupForHost(caller.org, sequence.hostId).groupId,\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","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","caller","gate","Response","sequenceId","firestore","org","collection","doc","orgId","snapshot","get","sequence","exists","data","requested","stepIndex","steps","step","kind","orgSettings","mailboxSnapshot","template","contact","Promise","all","mailboxId","hostId","templateId","emailTemplates","contactData","mailbox","sender","name","displayName","email","sendAs","siteName","String","merge","contactGroupId","groupId","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 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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"}
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
18
+ type Firestore = FirebaseFirestore.Firestore;
19
+ /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */
20
+ export declare function markOutreachLeadWorking(firestore: Firestore, input: {
21
+ hostId: string;
22
+ leadId: string;
23
+ }): Promise<boolean>;
24
+ /** Every enrollment made on the lead now names the contact it became. */
25
+ export declare function followOutreachLeadToContact(firestore: Firestore, input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>): Promise<PluginLeadConversionReport>;
26
+ export {};
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /**
17
+ * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).
18
+ *
19
+ * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's
20
+ * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead
21
+ * statuses), never through an import of the CRM plugin:
22
+ *
23
+ * - a REPLY moves a lead nobody has touched from New to Working. Somebody
24
+ * wrote back; the lead is being worked, and a queue that still showed it
25
+ * as New would send a rep to open a conversation already open. A lead
26
+ * already Working, closed, or converted is left as it is — a reply does
27
+ * not reopen a verdict or move a contact's record.
28
+ * - a CONVERSION re-points every enrollment made on the lead at the contact
29
+ * it became: the record system tells every plugin through its
30
+ * lead-conversion seam, and this is Sequences' share of it. The
31
+ * enrollment keeps its id, its thread and its place in the steps, and
32
+ * keeps naming the lead beside the contact, so both pages list it.
33
+ *
34
+ * Neither throws: a status that could not be moved is one a rep moves by
35
+ * hand, and an enrollment that could not follow its lead is one the next
36
+ * send stops as `contact_missing` and a rep re-enrolls.
37
+ */ import { crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
38
+ import { FieldValue } from "firebase-admin/firestore";
39
+ import { outreachOrgCollection } from "../storage/outreach-records.js";
40
+ /** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
41
+ /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */ export async function markOutreachLeadWorking(firestore, input) {
42
+ try {
43
+ var _snapshot_data;
44
+ const ref = firestore.collection('hosts').doc(input.hostId).collection('leads').doc(input.leadId);
45
+ const snapshot = await ref.get();
46
+ if (!snapshot.exists) return false;
47
+ const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
48
+ if (lead['convertedContactId'] || crmLeadStatus(lead) !== 'new') return false;
49
+ await ref.set({
50
+ status: 'working',
51
+ updatedAt: FieldValue.serverTimestamp()
52
+ }, {
53
+ merge: true
54
+ });
55
+ return true;
56
+ } catch (error) {
57
+ console.error('[outreach] the lead could not be marked working', input.hostId, input.leadId, error);
58
+ return false;
59
+ }
60
+ }
61
+ /** Every enrollment made on the lead now names the contact it became. */ export async function followOutreachLeadToContact(firestore, input) {
62
+ const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments');
63
+ const rows = await enrollments.where('leadId', '==', input.leadId).get();
64
+ const refs = rows.docs.filter((row)=>{
65
+ var _row_get;
66
+ return row.get('target') !== 'contact' || String((_row_get = row.get('contactId')) != null ? _row_get : '') !== input.contactId;
67
+ }).map((row)=>row.ref);
68
+ for(let start = 0; start < refs.length; start += BATCH_LIMIT){
69
+ const batch = firestore.batch();
70
+ for (const ref of refs.slice(start, start + BATCH_LIMIT)){
71
+ batch.update(ref, {
72
+ target: 'contact',
73
+ contactId: input.contactId,
74
+ updatedAtMs: Date.now()
75
+ });
76
+ }
77
+ await batch.commit();
78
+ }
79
+ return {
80
+ enrollments: refs.length
81
+ };
82
+ }
83
+
84
+ //# sourceMappingURL=lead-records.js.map
@@ -0,0 +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"}
@@ -30,7 +30,7 @@ import { markOutreachMailboxReconnectRequired } from "../mailboxes/mailbox-trans
30
30
  import { effectiveOutreachAllowedCountries } from "../model/compliance-settings.js";
31
31
  import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
32
32
  import { readOutreachComplianceSettingsDoc } from "../storage/compliance-settings-store.js";
33
- import { outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
33
+ import { outreachEnrollmentLink, outreachEnrollmentPerson, outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox, readStoredOutreachSequence } from "../storage/outreach-records.js";
34
34
  import { GmailTransportError, isReconnectRequired } from "../transport/gmail-errors.js";
35
35
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
36
36
  import { sendComposedOutreachEmail } from "../transport/send-message.js";
@@ -246,25 +246,32 @@ async function runMailbox(deps, firestore, input) {
246
246
  for (const [hostId, enrollments] of byHost){
247
247
  var _host_get;
248
248
  const contactGroupId = consentGroupForHost(org, hostId).groupId;
249
+ // Each by the record it names — the contact, or the lead while the
250
+ // person is one (AGL-3234) — keyed by that record's id.
249
251
  const [candidatesRead, lookupsRead, host] = await Promise.all([
250
252
  readOutreachEnrollCandidates(firestore, {
251
253
  orgId,
252
254
  hostId,
253
255
  contactGroupId,
254
- contactIds: enrollments.map((enrollment)=>enrollment.contactId)
256
+ people: enrollments.map(outreachEnrollmentPerson)
255
257
  }),
256
258
  readOutreachGateLookups(firestore, {
257
259
  orgId,
258
260
  hostId,
259
- people: enrollments.map((enrollment)=>({
260
- contactId: enrollment.contactId,
261
+ people: enrollments.map((enrollment)=>{
262
+ const person = outreachEnrollmentPerson(enrollment);
263
+ return {
264
+ personId: person.id,
265
+ contactId: person.kind === 'contact' ? person.id : null,
266
+ leadId: person.kind === 'lead' ? person.id : null,
261
267
  email: enrollment.email
262
- }))
268
+ };
269
+ })
263
270
  }),
264
271
  firestore.collection('hosts').doc(hostId).get()
265
272
  ]);
266
- for (const person of candidatesRead)people.set(person.contactId, person);
267
- for (const [contactId, answer] of lookupsRead)lookups.set(contactId, answer);
273
+ for (const person of candidatesRead)people.set(person.personId, person);
274
+ for (const [personId, answer] of lookupsRead)lookups.set(personId, answer);
268
275
  siteNames.set(hostId, host.exists ? String((_host_get = host.get('name')) != null ? _host_get : '') : '');
269
276
  }
270
277
  for (const candidate of selection.emails){
@@ -274,11 +281,12 @@ async function runMailbox(deps, firestore, input) {
274
281
  continue;
275
282
  }
276
283
  const enrollment = candidate.enrollment;
284
+ const personId = outreachEnrollmentPerson(enrollment).id;
277
285
  await runEmailStep(deps, firestore, run, {
278
286
  enrollment,
279
287
  sequence: candidate.sequence,
280
- person: (_people_get = people.get(enrollment.contactId)) != null ? _people_get : null,
281
- lookups: (_lookups_get = lookups.get(enrollment.contactId)) != null ? _lookups_get : null,
288
+ person: (_people_get = people.get(personId)) != null ? _people_get : null,
289
+ lookups: (_lookups_get = lookups.get(personId)) != null ? _lookups_get : null,
282
290
  siteName: (_siteNames_get = siteNames.get(enrollment.hostId)) != null ? _siteNames_get : ''
283
291
  });
284
292
  }
@@ -470,7 +478,7 @@ async function openClient(deps, firestore, run) {
470
478
  const written = await fileOutreachTask(deps, {
471
479
  orgId: run.orgId,
472
480
  hostId: enrollment.hostId,
473
- contactId: enrollment.contactId,
481
+ link: outreachEnrollmentLink(enrollment),
474
482
  dedupeKey: `step:${enrollment.id}:${step.id}`,
475
483
  title: step.title,
476
484
  notes: `From sequence step ${enrollment.stepIndex + 1}.`,
@@ -530,9 +538,15 @@ async function runEmailStep(deps, firestore, run, input) {
530
538
  atMs: nowMs,
531
539
  byUid: null,
532
540
  reason: 'gate',
533
- detail: 'This contact no longer exists in the CRM.'
541
+ detail: enrollment.target === 'lead' ? "This lead no longer exists in the sequence's site's CRM." : 'This contact no longer exists in the CRM.'
534
542
  }, 'stopped');
535
543
  }
544
+ if (person.convertedContactId && enrollment.target === 'lead') {
545
+ // The lead converted and the enrollment has not followed it yet: the
546
+ // conversion's listener re-points it, and the next run sends as the contact.
547
+ report.held += 1;
548
+ return;
549
+ }
536
550
  if (!person.visible) {
537
551
  return stop({
538
552
  type: 'stop',
@@ -582,8 +596,12 @@ async function runEmailStep(deps, firestore, run, input) {
582
596
  enrollment,
583
597
  orgSettings: run.settings,
584
598
  merge: {
599
+ // For a lead, the contact-shaped view of it (`leadAsContact`), so a
600
+ // step written with `{{contact.*}}` reads the lead; `{{lead.*}}` reads
601
+ // the lead's own document beside it.
585
602
  contact: person.contact,
586
603
  contactGroupId,
604
+ lead: person.lead,
587
605
  sender: {
588
606
  name: mailbox.displayName,
589
607
  email: sender.address
@@ -707,7 +725,7 @@ async function runEmailStep(deps, firestore, run, input) {
707
725
  await fileOutreachEmail(deps, {
708
726
  orgId: run.orgId,
709
727
  hostId: enrollment.hostId,
710
- contactId: enrollment.contactId,
728
+ link: outreachEnrollmentLink(enrollment),
711
729
  direction: 'outbound',
712
730
  subject: sent.subject,
713
731
  from: sender.address,