@aglyn/plugins-outreach 1.0.0-beta.220 → 1.0.0-beta.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/do-not-contact-domains.d.ts +6 -2
- package/src/lib/components/do-not-contact-domains.js +73 -11
- package/src/lib/components/do-not-contact-domains.js.map +1 -1
- package/src/lib/components/enrollments-table.d.ts +6 -0
- package/src/lib/components/enrollments-table.js +77 -4
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/sequence-detail.js +3 -1
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.js +7 -3
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/use-outreach-crm.d.ts +18 -15
- package/src/lib/components/use-outreach-crm.js +118 -87
- package/src/lib/components/use-outreach-crm.js.map +1 -1
- package/src/lib/components/use-outreach-data.d.ts +2 -2
- package/src/lib/components/use-outreach-data.js.map +1 -1
- package/src/lib/constants/transfer-resources.d.ts +33 -0
- package/src/lib/constants/transfer-resources.js +38 -0
- package/src/lib/constants/transfer-resources.js.map +1 -0
- package/src/lib/declarations.console-server.d.ts +7 -1
- package/src/lib/declarations.console-server.js +66 -1
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/engine/click-tracking.js +1 -1
- package/src/lib/engine/click-tracking.js.map +1 -1
- package/src/lib/engine/ip-range.d.ts +26 -0
- package/src/lib/engine/ip-range.js +78 -0
- package/src/lib/engine/ip-range.js.map +1 -0
- package/src/lib/engine/open-source.d.ts +24 -0
- package/src/lib/engine/open-source.js +130 -0
- package/src/lib/engine/open-source.js.map +1 -0
- package/src/lib/engine/open-tracking.d.ts +15 -2
- package/src/lib/engine/open-tracking.js +59 -16
- package/src/lib/engine/open-tracking.js.map +1 -1
- package/src/lib/engine/outreach-access.d.ts +47 -0
- package/src/lib/engine/outreach-access.js +56 -0
- package/src/lib/engine/outreach-access.js.map +1 -0
- package/src/lib/enrollment/enroll-people.d.ts +10 -10
- package/src/lib/enrollment/enroll-people.js +30 -25
- package/src/lib/enrollment/enroll-people.js.map +1 -1
- package/src/lib/enrollment/gate-lookups.js +30 -15
- package/src/lib/enrollment/gate-lookups.js.map +1 -1
- package/src/lib/enrollment/message-templates.d.ts +24 -0
- package/src/lib/enrollment/message-templates.js +35 -0
- package/src/lib/enrollment/message-templates.js.map +1 -0
- package/src/lib/model/enrollment-engagement.d.ts +16 -0
- package/src/lib/model/enrollment-engagement.js +16 -0
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +8 -1
- package/src/lib/model/enrollment-history.js +17 -5
- package/src/lib/model/enrollment-history.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +3 -0
- package/src/lib/model/enrollment-timeline.js +15 -4
- package/src/lib/model/enrollment-timeline.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +12 -3
- package/src/lib/model/outreach.types.js +12 -1
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-report.d.ts +14 -7
- package/src/lib/model/sequence-report.js +12 -8
- package/src/lib/model/sequence-report.js.map +1 -1
- package/src/lib/plugin.js +18 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/routes/curate-routes.js +4 -6
- package/src/lib/routes/curate-routes.js.map +1 -1
- package/src/lib/routes/enroll-routes.d.ts +0 -12
- package/src/lib/routes/enroll-routes.js +52 -92
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +4 -16
- package/src/lib/routes/platform-deps.js +5 -25
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/preview-routes.js +25 -13
- package/src/lib/routes/preview-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +7 -5
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-gate.js +6 -17
- package/src/lib/routes/route-gate.js.map +1 -1
- package/src/lib/routes/sequence-routes.d.ts +46 -0
- package/src/lib/routes/sequence-routes.js +155 -63
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/click-events.js +8 -8
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/click-route.js +5 -2
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/lead-records.d.ts +18 -10
- package/src/lib/runtime/lead-records.js +14 -60
- package/src/lib/runtime/lead-records.js.map +1 -1
- package/src/lib/runtime/open-events.d.ts +8 -0
- package/src/lib/runtime/open-events.js +10 -3
- package/src/lib/runtime/open-events.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.d.ts +9 -5
- package/src/lib/runtime/platform-runtime-deps.js +31 -24
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +20 -17
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +10 -5
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/do-not-contact-store.d.ts +8 -0
- package/src/lib/storage/do-not-contact-store.js +13 -0
- package/src/lib/storage/do-not-contact-store.js.map +1 -1
- package/src/lib/testing/stand-in-record-system.d.ts +54 -0
- package/src/lib/testing/stand-in-record-system.js +243 -0
- package/src/lib/testing/stand-in-record-system.js.map +1 -0
- package/src/lib/transfer/do-not-contact-transfer.d.ts +129 -0
- package/src/lib/transfer/do-not-contact-transfer.js +801 -0
- package/src/lib/transfer/do-not-contact-transfer.js.map +1 -0
- package/src/lib/transfer/plan-gate.d.ts +25 -0
- package/src/lib/transfer/plan-gate.js +35 -0
- package/src/lib/transfer/plan-gate.js.map +1 -0
- package/src/lib/transfer/platform-transfer-deps.d.ts +24 -0
- package/src/lib/transfer/platform-transfer-deps.js +39 -0
- package/src/lib/transfer/platform-transfer-deps.js.map +1 -0
- package/src/lib/transfer/sequences-package.d.ts +48 -0
- package/src/lib/transfer/sequences-package.js +93 -0
- package/src/lib/transfer/sequences-package.js.map +1 -0
- package/src/lib/transfer/sequences-package.server.d.ts +31 -0
- package/src/lib/transfer/sequences-package.server.js +207 -0
- package/src/lib/transfer/sequences-package.server.js.map +1 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/sequence-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 { normalizeContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOrgContainers } from '@aglyn/tenant-data-admin/server/org-containers'\nimport { applyOutreachEnrollmentEvent } from '../engine/enrollment-state'\nimport {\n validateOutreachSequence,\n validateOutreachSequenceActivation,\n} from '../engine/sequence-validation'\nimport type {\n OutreachSequenceAction,\n OutreachSequenceSaveResponse,\n OutreachSequenceStatusResponse,\n} from '../model/outreach-api'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachMailbox, OutreachSequence, OutreachSequenceStatus } from '../model/outreach.types'\nimport {\n outreachEnrolledSequenceIssues,\n outreachMailboxActivationIssue,\n readOutreachSequenceDraft,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * THE SEQUENCE ROUTES (AGL-2980): save, activate, pause, archive, delete.\n *\n * ## Who may change a sequence\n *\n * A sequence sends as the member whose mailbox it names, so it is theirs to\n * change: the member who connected the mailbox, or an organization owner or\n * admin, may save it, activate it and send from it — the same two the\n * mailbox's own routes let manage the mailbox. Anyone with `outreach.use`\n * may pause or archive any sequence, because stopping mail is never the act\n * that needs guarding.\n *\n * ## Saving\n *\n * The draft is read with the editor's reader and judged by the engine's\n * validator, so the save refuses exactly what the editor showed beside a\n * field. On top of that, the site must be one of the organization's, the\n * mailbox one of its connected mailboxes, the countries ones the\n * organization allows, and — once anyone is enrolled — the steps, site and\n * mailbox fixed (`outreachEnrolledSequenceIssues`). A new sequence is a\n * draft; an edit keeps the status it had, and an active sequence stays\n * active only because the edit passed the same validator activation does.\n *\n * ## Activating\n *\n * `validateOutreachSequenceActivation`: everything a save refuses on, and an\n * organization whose footer cannot be written — no legal name, or no postal\n * address. And its mailbox must be sending (`outreachMailboxActivationIssue`):\n * chosen, still connected, not paused, and not waiting to be reconnected —\n * the same words the sequence page shows beside its disabled Activate.\n *\n * ## Archiving\n *\n * Final. Every person still active or paused in the sequence is stopped\n * with `sequence_archived`, in batches, so no archived sequence goes on\n * sending.\n */\n\n/** A batch holds 500 writes; a page of enrollments stops this many. */\nconst ARCHIVE_PAGE = 400\n\n/** The activity line each lifecycle act writes. */\nexport const OUTREACH_SEQUENCE_ACTIVITY = {\n create: 'Created a sequence',\n activate: 'Activated a sequence',\n pause: 'Paused a sequence',\n archive: 'Archived a sequence',\n delete: 'Deleted a sequence',\n} as const\n\n/** Where each action may start, and where it ends. */\nconst TRANSITIONS: Record<\n OutreachSequenceAction,\n { from: readonly OutreachSequenceStatus[]; to: OutreachSequenceStatus }\n> = {\n activate: { from: ['draft', 'paused'], to: 'active' },\n pause: { from: ['active'], to: 'paused' },\n archive: { from: ['draft', 'active', 'paused'], to: 'archived' },\n}\n\nconst ACTION_REFUSALS: Record<OutreachSequenceAction, string> = {\n activate: 'Only a draft or paused sequence can be activated.',\n pause: 'Only an active sequence can be paused.',\n archive: 'This sequence is already archived.',\n}\n\nexport interface OutreachSequenceRoutes {\n save: PluginWebApiHandler\n status: PluginWebApiHandler\n remove: PluginWebApiHandler\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\nasync function loadSequence(\n firestore: Firestore,\n orgId: string,\n sequenceId: string,\n): Promise<OutreachSequence | null> {\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n return readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n}\n\n/** Whether anyone was ever enrolled in the sequence. */\nasync function hasEnrollments(firestore: Firestore, orgId: string, sequenceId: string): Promise<boolean> {\n const found = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('sequenceId', '==', sequenceId)\n .limit(1)\n .get()\n return !found.empty\n}\n\nconst issue = (\n path: string,\n code: OutreachSequenceIssue['code'],\n message: string,\n): OutreachSequenceIssue => ({ path, code, message, severity: 'error' })\n\n/**\n * The organization-level facts a draft has to agree with: its site is the\n * organization's, its mailbox is connected and the caller may send from it,\n * and its countries are ones the organization allows. `activating` adds\n * that the mailbox is sending now — chosen, and neither paused nor waiting\n * to be reconnected.\n */\nasync function draftPlacementIssues(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n // A draft, or a stored sequence being activated, which may predate the\n // campaign field (AGL-3254).\n draft: Pick<OutreachSequenceDraft, 'hostId' | 'mailboxId' | 'settings'> & Pick<OutreachSequence, 'campaignIds'>,\n options: { activating?: boolean } = {},\n): Promise<OutreachSequenceIssue[]> {\n const issues: OutreachSequenceIssue[] = []\n const campaignIds = normalizeContainerIds(draft.campaignIds)\n if (draft.hostId) {\n const host = readOutreachDocumentId(draft.hostId)\n ? await firestore.collection('hosts').doc(draft.hostId).get()\n : null\n if (!host?.exists || host.get('orgId') !== caller.orgId) {\n issues.push(issue('hostId', 'host_unknown', \"Choose one of this organization's sites.\"))\n }\n } else {\n issues.push(issue('hostId', 'host_unknown', \"Choose the site whose contacts this sequence emails.\"))\n }\n const mailbox = draft.mailboxId ? await loadMailbox(firestore, caller.orgId, draft.mailboxId) : null\n const activation = options.activating ? outreachMailboxActivationIssue(draft.mailboxId, mailbox) : null\n if (draft.mailboxId) {\n const placement = mailboxIssues(caller, mailbox)\n // A mailbox that is gone is refused once, in activation's words when\n // activating: they say what to do next.\n issues.push(\n ...(activation?.code === 'mailbox_unknown'\n ? placement.filter((entry) => entry.code !== 'mailbox_unknown')\n : placement),\n )\n }\n if (activation && !issues.some((entry) => entry.code === 'mailbox_not_yours')) issues.push(activation)\n const settings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const outside = draft.settings.allowedCountries.filter(\n (code) => !settings.allowedCountries.includes(code),\n )\n if (outside.length) {\n issues.push(\n issue(\n 'settings.allowedCountries',\n 'country_not_in_org',\n `${outside.join(', ')} ${outside.length === 1 ? \"isn't\" : \"aren't\"} among the countries your organization allows. Add ${outside.length === 1 ? 'it' : 'them'} in Sequences compliance settings first.`,\n ),\n )\n }\n /*\n * The campaigns must be the ORGANIZATION's live containers of the\n * `campaign` kind (AGL-3254): a request can claim any id,\n * and a sequence filed under another org's campaign, or under one the\n * console soft-deleted, would credit its outcomes to a page nobody here\n * can open. A sequence is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on.\n */\n if (campaignIds.length) {\n const found = await readOrgContainers(\n firestore,\n 'campaign',\n caller.orgId,\n campaignIds.map((campaignId) => readOutreachDocumentId(campaignId) ?? ''),\n )\n const unknown = campaignIds.filter(\n (campaignId, index) => !readOutreachDocumentId(campaignId) || !found[index]?.live,\n )\n if (unknown.length) {\n issues.push(\n issue(\n 'campaignIds',\n 'campaign_unknown',\n unknown.length === 1\n ? \"One of the campaigns picked isn't one of this organization's campaigns any more. Pick it again.\"\n : \"Some of the campaigns picked aren't this organization's campaigns any more. Pick them again.\",\n ),\n )\n }\n }\n return issues\n}\n\n/** A mailbox of this organization's, as stored; `null` when there is none by that id. */\nasync function loadMailbox(firestore: Firestore, orgId: string, mailboxId: string): Promise<OutreachMailbox | null> {\n const snapshot = readOutreachDocumentId(mailboxId)\n ? await outreachOrgCollection(firestore, orgId, 'mailboxes').doc(mailboxId).get()\n : null\n return readStoredOutreachMailbox(mailboxId, snapshot?.exists ? snapshot.data() : undefined)\n}\n\n/** Whether the mailbox is connected here, and the caller may send from it. */\nfunction mailboxIssues(caller: OutreachRouteCaller, mailbox: OutreachMailbox | null): OutreachSequenceIssue[] {\n if (!mailbox || mailbox.status === 'disconnected') {\n return [issue('mailboxId', 'mailbox_unknown', 'That mailbox is not connected to this organization.')]\n }\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return [\n issue(\n 'mailboxId',\n 'mailbox_not_yours',\n 'Only the member who connected this mailbox, or an organization owner or admin, can send from it.',\n ),\n ]\n }\n return []\n}\n\n/** One issue per field and code: activation and the engine can both ask for a mailbox. */\nfunction distinctIssues(issues: readonly OutreachSequenceIssue[]): OutreachSequenceIssue[] {\n const seen = new Set<string>()\n return issues.filter((entry) => {\n const key = `${entry.path}|${entry.code}`\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n}\n\nexport function createOutreachSequenceRoutes(deps: OutreachRouteDeps): OutreachSequenceRoutes {\n const activity = (caller: OutreachRouteCaller, action: string, sequence: Pick<OutreachSequence, 'id' | 'name'>) =>\n deps.logOrgActivity(caller.orgId, { uid: caller.uid, email: caller.email }, action, {\n type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,\n id: sequence.id,\n name: sequence.name,\n })\n\n const save: 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)\n if (caller instanceof Response) return caller\n const firestore = deps.firestore()\n\n const rawId = body['sequenceId']\n const sequenceId = rawId === undefined || rawId === null || rawId === '' ? null : readOutreachDocumentId(rawId)\n if (rawId && !sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to save.')\n const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null\n if (sequenceId && !existing) {\n return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n }\n if (existing?.status === 'archived') {\n return outreachRefusal(409, 'sequence-archived', \"An archived sequence can't be edited.\")\n }\n\n const draft = readOutreachSequenceDraft(body['sequence'])\n // A sequence sends as its mailbox's member, so it is theirs (or an\n // admin's) to change — the stored mailbox as well as a new one: moving\n // a colleague's sequence onto your own mailbox is still changing theirs.\n if (existing?.mailboxId && existing.mailboxId !== draft.mailboxId) {\n const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId))\n const refused = stored.find((entry) => entry.code === 'mailbox_not_yours')\n if (refused) return outreachRefusal(403, 'permission', refused.message)\n }\n const judged: OutreachSequenceIssue[] = [\n ...validateOutreachSequence(draft),\n ...(await draftPlacementIssues(firestore, caller, draft)),\n ]\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n if (ownership) return outreachRefusal(403, 'permission', ownership.message)\n if (existing && (await hasEnrollments(firestore, caller.orgId, existing.id))) {\n judged.push(...outreachEnrolledSequenceIssues(existing, draft))\n }\n const errors = judged.filter((entry) => entry.severity === 'error')\n if (errors.length) {\n return outreachRefusal(400, 'invalid-sequence', errors[0].message, { issues: judged })\n }\n\n const nowMs = deps.now()\n const ref = existing\n ? outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(existing.id)\n : outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(createResourceUid())\n const sequence: OutreachSequence = {\n id: ref.id,\n ...draft,\n status: existing?.status ?? 'draft',\n /*\n * Carried through explicitly, because the write below REPLACES the\n * document (AGL-3239). The counters are the runtime's and the click\n * route's, never the editor's, and a member renaming a sequence must\n * not reset what it measured.\n */\n ...(existing?.stats ? { stats: existing.stats } : {}),\n createdAtMs: existing?.createdAtMs || nowMs,\n updatedAtMs: nowMs,\n }\n /*\n * The list's search fields ride with every write of the name (AGL-3321):\n * the sequences list asks Firestore for them, and a sequence missing\n * them would list normally and never be found.\n */\n await ref.set({ ...sequence, ...nameSearchFields(sequence.name) })\n if (!existing) await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.create, sequence)\n return outreachOk({\n ok: true,\n sequence,\n created: !existing,\n warnings: judged.filter((entry) => entry.severity === 'warning'),\n } satisfies OutreachSequenceSaveResponse)\n }\n\n const status: 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)\n if (caller instanceof Response) return caller\n const action = body['action'] as OutreachSequenceAction\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId || !Object.prototype.hasOwnProperty.call(TRANSITIONS, action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the sequence, and activate, pause or archive.')\n }\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n const transition = TRANSITIONS[action]\n if (sequence.status === transition.to) {\n return outreachOk({ ok: true, sequence, stoppedEnrollments: 0 } satisfies OutreachSequenceStatusResponse)\n }\n if (!transition.from.includes(sequence.status)) {\n return outreachRefusal(409, 'transition-refused', ACTION_REFUSALS[action])\n }\n\n if (action === 'activate') {\n const orgSettings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const judged: OutreachSequenceIssue[] = distinctIssues([\n ...validateOutreachSequenceActivation(sequence, orgSettings),\n ...(await draftPlacementIssues(firestore, caller, sequence, { activating: true })),\n ]).filter((entry) => entry.severity === 'error')\n if (judged.length) {\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n return ownership\n ? outreachRefusal(403, 'permission', ownership.message)\n : outreachRefusal(409, 'activation-refused', judged[0].message, { issues: judged })\n }\n }\n\n const nowMs = deps.now()\n const next: OutreachSequence = { ...sequence, status: transition.to, updatedAtMs: nowMs }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences')\n .doc(sequence.id)\n .update({ status: next.status, updatedAtMs: nowMs })\n const stoppedEnrollments =\n action === 'archive' ? await stopOpenEnrollments(firestore, caller, sequence.id, nowMs) : 0\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY[action], sequence)\n return outreachOk({ ok: true, sequence: next, stoppedEnrollments } satisfies OutreachSequenceStatusResponse)\n }\n\n const remove: 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)\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 delete.')\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n if (sequence.status !== 'draft' || (await hasEnrollments(firestore, caller.orgId, sequence.id))) {\n // What people were sent, and why they stopped, is the record of what\n // this organization did; only a draft nobody was enrolled in has none.\n return outreachRefusal(\n 409,\n 'delete-refused',\n 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.',\n )\n }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete()\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY.delete, sequence)\n return outreachOk({ ok: true })\n }\n\n return { save, status, remove }\n}\n\n/**\n * Stops everyone still active or paused in an archived sequence, a page at\n * a time, through the engine's own transition. Returns how many it stopped.\n */\nasync function stopOpenEnrollments(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequenceId: string,\n nowMs: number,\n): Promise<number> {\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n let stopped = 0\n let after: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (;;) {\n let page = enrollments.where('sequenceId', '==', sequenceId).orderBy('__name__').limit(ARCHIVE_PAGE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n const batch = firestore.batch()\n let writes = 0\n for (const doc of snapshot.docs) {\n const enrollment = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!enrollment || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(enrollment, {\n type: 'stop',\n reason: 'sequence_archived',\n atMs: nowMs,\n byUid: caller.uid,\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n stopped += writes\n after = snapshot.docs[snapshot.docs.length - 1]\n if (snapshot.size < ARCHIVE_PAGE) break\n }\n return stopped\n}\n"],"names":["normalizeContainerIds","createResourceUid","nameSearchFields","readOrgContainers","applyOutreachEnrollmentEvent","validateOutreachSequence","validateOutreachSequenceActivation","OUTREACH_OPEN_ENROLLMENT_STATUSES","outreachEnrolledSequenceIssues","outreachMailboxActivationIssue","readOutreachSequenceDraft","readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ARCHIVE_PAGE","OUTREACH_SEQUENCE_ACTIVITY","create","activate","pause","archive","delete","TRANSITIONS","from","to","ACTION_REFUSALS","loadSequence","firestore","orgId","sequenceId","snapshot","doc","get","exists","data","undefined","hasEnrollments","found","where","limit","empty","issue","path","code","message","severity","draftPlacementIssues","caller","draft","options","issues","campaignIds","hostId","host","collection","push","mailbox","mailboxId","loadMailbox","activation","activating","placement","mailboxIssues","filter","entry","some","settings","outside","allowedCountries","includes","length","join","map","campaignId","unknown","index","live","status","connectedByUid","uid","isOrgAdmin","distinctIssues","seen","Set","key","has","add","createOutreachSequenceRoutes","deps","activity","action","sequence","logOrgActivity","email","type","id","name","save","request","method","body","gate","Response","rawId","existing","stored","refused","find","judged","ownership","errors","nowMs","now","ref","stats","createdAtMs","updatedAtMs","set","ok","created","warnings","Object","prototype","hasOwnProperty","call","transition","stoppedEnrollments","orgSettings","next","update","stopOpenEnrollments","remove","enrollments","stopped","after","page","orderBy","startAfter","batch","writes","docs","enrollment","patch","reason","atMs","byUid","commit","size"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/sequence-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 { normalizeContainerIds } from '@aglyn/aglyn/app-utils/container-membership'\nimport { createResourceUid } from '@aglyn/aglyn/app-utils/create-resource-uid'\nimport { nameSearchFields } from '@aglyn/aglyn/app-utils/name-search'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOrgContainers } from '@aglyn/tenant-data-admin/server/org-containers'\nimport { applyOutreachEnrollmentEvent } from '../engine/enrollment-state'\nimport {\n validateOutreachSequence,\n validateOutreachSequenceActivation,\n} from '../engine/sequence-validation'\nimport type {\n OutreachSequenceAction,\n OutreachSequenceSaveResponse,\n OutreachSequenceStatusResponse,\n} from '../model/outreach-api'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type { OutreachMailbox, OutreachSequence, OutreachSequenceStatus } from '../model/outreach.types'\nimport {\n outreachEnrolledSequenceIssues,\n outreachMailboxActivationIssue,\n readOutreachSequenceDraft,\n type OutreachSequenceDraft,\n type OutreachSequenceIssue,\n} from '../model/sequence-draft'\nimport { readOutreachComplianceSettingsDoc } from '../storage/compliance-settings-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n readStoredOutreachSequence,\n} from '../storage/outreach-records'\nimport { OUTREACH_SEQUENCE_ACTIVITY_TARGET, type OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate, type OutreachRouteCaller } from './route-gate'\nimport type { OutreachRouteRefusalReason } from '../model/outreach-api'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * THE SEQUENCE ROUTES (AGL-2980): save, activate, pause, archive, delete.\n *\n * ## Who may change a sequence\n *\n * A sequence sends as the member whose mailbox it names, so it is theirs to\n * change: the member who connected the mailbox, or an organization owner or\n * admin, may save it, activate it and send from it — the same two the\n * mailbox's own routes let manage the mailbox. Anyone with `outreach.use`\n * may pause or archive any sequence, because stopping mail is never the act\n * that needs guarding.\n *\n * ## Saving\n *\n * The draft is read with the editor's reader and judged by the engine's\n * validator, so the save refuses exactly what the editor showed beside a\n * field. On top of that, the site must be one of the organization's, the\n * mailbox one of its connected mailboxes, the countries ones the\n * organization allows, and — once anyone is enrolled — the steps, site and\n * mailbox fixed (`outreachEnrolledSequenceIssues`). A new sequence is a\n * draft; an edit keeps the status it had, and an active sequence stays\n * active only because the edit passed the same validator activation does.\n *\n * ## Activating\n *\n * `validateOutreachSequenceActivation`: everything a save refuses on, and an\n * organization whose footer cannot be written — no legal name, or no postal\n * address. And its mailbox must be sending (`outreachMailboxActivationIssue`):\n * chosen, still connected, not paused, and not waiting to be reconnected —\n * the same words the sequence page shows beside its disabled Activate.\n *\n * ## Archiving\n *\n * Final. Every person still active or paused in the sequence is stopped\n * with `sequence_archived`, in batches, so no archived sequence goes on\n * sending.\n */\n\n/** A batch holds 500 writes; a page of enrollments stops this many. */\nconst ARCHIVE_PAGE = 400\n\n/** The activity line each lifecycle act writes. */\nexport const OUTREACH_SEQUENCE_ACTIVITY = {\n create: 'Created a sequence',\n activate: 'Activated a sequence',\n pause: 'Paused a sequence',\n archive: 'Archived a sequence',\n delete: 'Deleted a sequence',\n} as const\n\n/** Where each action may start, and where it ends. */\nconst TRANSITIONS: Record<\n OutreachSequenceAction,\n { from: readonly OutreachSequenceStatus[]; to: OutreachSequenceStatus }\n> = {\n activate: { from: ['draft', 'paused'], to: 'active' },\n pause: { from: ['active'], to: 'paused' },\n archive: { from: ['draft', 'active', 'paused'], to: 'archived' },\n}\n\nconst ACTION_REFUSALS: Record<OutreachSequenceAction, string> = {\n activate: 'Only a draft or paused sequence can be activated.',\n pause: 'Only an active sequence can be paused.',\n archive: 'This sequence is already archived.',\n}\n\nexport interface OutreachSequenceRoutes {\n save: PluginWebApiHandler\n status: PluginWebApiHandler\n remove: PluginWebApiHandler\n}\n\ntype Firestore = FirebaseFirestore.Firestore\n\nasync function loadSequence(\n firestore: Firestore,\n orgId: string,\n sequenceId: string,\n): Promise<OutreachSequence | null> {\n const snapshot = await outreachOrgCollection(firestore, orgId, 'sequences').doc(sequenceId).get()\n return readStoredOutreachSequence(sequenceId, snapshot.exists ? snapshot.data() : undefined)\n}\n\n/** Whether anyone was ever enrolled in the sequence. */\nasync function hasEnrollments(firestore: Firestore, orgId: string, sequenceId: string): Promise<boolean> {\n const found = await outreachOrgCollection(firestore, orgId, 'enrollments')\n .where('sequenceId', '==', sequenceId)\n .limit(1)\n .get()\n return !found.empty\n}\n\nconst issue = (\n path: string,\n code: OutreachSequenceIssue['code'],\n message: string,\n): OutreachSequenceIssue => ({ path, code, message, severity: 'error' })\n\n/**\n * The organization-level facts a draft has to agree with: its site is the\n * organization's, its mailbox is connected and the caller may send from it,\n * and its countries are ones the organization allows. `activating` adds\n * that the mailbox is sending now — chosen, and neither paused nor waiting\n * to be reconnected.\n */\nasync function draftPlacementIssues(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n // A draft, or a stored sequence being activated, which may predate the\n // campaign field (AGL-3254).\n draft: Pick<OutreachSequenceDraft, 'hostId' | 'mailboxId' | 'settings'> & Pick<OutreachSequence, 'campaignIds'>,\n options: { activating?: boolean } = {},\n): Promise<OutreachSequenceIssue[]> {\n const issues: OutreachSequenceIssue[] = []\n const campaignIds = normalizeContainerIds(draft.campaignIds)\n if (draft.hostId) {\n const host = readOutreachDocumentId(draft.hostId)\n ? await firestore.collection('hosts').doc(draft.hostId).get()\n : null\n if (!host?.exists || host.get('orgId') !== caller.orgId) {\n issues.push(issue('hostId', 'host_unknown', \"Choose one of this organization's sites.\"))\n }\n } else {\n issues.push(issue('hostId', 'host_unknown', \"Choose the site whose contacts this sequence emails.\"))\n }\n const mailbox = draft.mailboxId ? await loadMailbox(firestore, caller.orgId, draft.mailboxId) : null\n const activation = options.activating ? outreachMailboxActivationIssue(draft.mailboxId, mailbox) : null\n if (draft.mailboxId) {\n const placement = mailboxIssues(caller, mailbox)\n // A mailbox that is gone is refused once, in activation's words when\n // activating: they say what to do next.\n issues.push(\n ...(activation?.code === 'mailbox_unknown'\n ? placement.filter((entry) => entry.code !== 'mailbox_unknown')\n : placement),\n )\n }\n if (activation && !issues.some((entry) => entry.code === 'mailbox_not_yours')) issues.push(activation)\n const settings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const outside = draft.settings.allowedCountries.filter(\n (code) => !settings.allowedCountries.includes(code),\n )\n if (outside.length) {\n issues.push(\n issue(\n 'settings.allowedCountries',\n 'country_not_in_org',\n `${outside.join(', ')} ${outside.length === 1 ? \"isn't\" : \"aren't\"} among the countries your organization allows. Add ${outside.length === 1 ? 'it' : 'them'} in Sequences compliance settings first.`,\n ),\n )\n }\n /*\n * The campaigns must be the ORGANIZATION's live containers of the\n * `campaign` kind (AGL-3254): a request can claim any id,\n * and a sequence filed under another org's campaign, or under one the\n * console soft-deleted, would credit its outcomes to a page nobody here\n * can open. A sequence is an org record, so any live campaign of the org\n * qualifies, whichever sites it is placed on.\n */\n if (campaignIds.length) {\n const found = await readOrgContainers(\n firestore,\n 'campaign',\n caller.orgId,\n campaignIds.map((campaignId) => readOutreachDocumentId(campaignId) ?? ''),\n )\n const unknown = campaignIds.filter(\n (campaignId, index) => !readOutreachDocumentId(campaignId) || !found[index]?.live,\n )\n if (unknown.length) {\n issues.push(\n issue(\n 'campaignIds',\n 'campaign_unknown',\n unknown.length === 1\n ? \"One of the campaigns picked isn't one of this organization's campaigns any more. Pick it again.\"\n : \"Some of the campaigns picked aren't this organization's campaigns any more. Pick them again.\",\n ),\n )\n }\n }\n return issues\n}\n\n/** A mailbox of this organization's, as stored; `null` when there is none by that id. */\nasync function loadMailbox(firestore: Firestore, orgId: string, mailboxId: string): Promise<OutreachMailbox | null> {\n const snapshot = readOutreachDocumentId(mailboxId)\n ? await outreachOrgCollection(firestore, orgId, 'mailboxes').doc(mailboxId).get()\n : null\n return readStoredOutreachMailbox(mailboxId, snapshot?.exists ? snapshot.data() : undefined)\n}\n\n/** Whether the mailbox is connected here, and the caller may send from it. */\nfunction mailboxIssues(caller: OutreachRouteCaller, mailbox: OutreachMailbox | null): OutreachSequenceIssue[] {\n if (!mailbox || mailbox.status === 'disconnected') {\n return [issue('mailboxId', 'mailbox_unknown', 'That mailbox is not connected to this organization.')]\n }\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return [\n issue(\n 'mailboxId',\n 'mailbox_not_yours',\n 'Only the member who connected this mailbox, or an organization owner or admin, can send from it.',\n ),\n ]\n }\n return []\n}\n\n/** One issue per field and code: activation and the engine can both ask for a mailbox. */\nfunction distinctIssues(issues: readonly OutreachSequenceIssue[]): OutreachSequenceIssue[] {\n const seen = new Set<string>()\n return issues.filter((entry) => {\n const key = `${entry.path}|${entry.code}`\n if (seen.has(key)) return false\n seen.add(key)\n return true\n })\n}\n\n/** What saving a sequence came to: the stored sequence, or the refusal the route answers with. */\nexport type OutreachSequenceSaveOutcome =\n | { ok: true; sequence: OutreachSequence; created: boolean; warnings: OutreachSequenceIssue[] }\n | { ok: false; status: number; code: OutreachRouteRefusalReason; message: string; issues?: OutreachSequenceIssue[] }\n\n/**\n * Saves a sequence the way the editor's Save does (see \"Saving\" above):\n * the editor's validator, the organization's placement rules and — once\n * anyone is enrolled — the fixed steps, site and mailbox. A new sequence is\n * a draft; an edit keeps its status. The save route and the package import\n * (AGL-3535) both write through here. `newId` names a new sequence's id\n * (an import keeps the package's); `judgeOnly` answers the verdict without\n * writing, for an import's dry run.\n */\nexport async function saveOutreachSequence(\n deps: Pick<OutreachRouteDeps, 'firestore' | 'now' | 'logOrgActivity'>,\n caller: OutreachRouteCaller,\n input: { sequenceId: string | null; draft: OutreachSequenceDraft; newId?: string; judgeOnly?: boolean },\n): Promise<OutreachSequenceSaveOutcome> {\n const firestore = deps.firestore()\n const { sequenceId, draft } = input\n const existing = sequenceId ? await loadSequence(firestore, caller.orgId, sequenceId) : null\n if (sequenceId && !existing && !input.newId) {\n return { ok: false, status: 404, code: 'sequence-not-found', message: 'That sequence no longer exists.' }\n }\n if (existing?.status === 'archived') {\n return { ok: false, status: 409, code: 'sequence-archived', message: \"An archived sequence can't be edited.\" }\n }\n // A sequence sends as its mailbox's member, so it is theirs (or an\n // admin's) to change — the stored mailbox as well as a new one: moving\n // a colleague's sequence onto your own mailbox is still changing theirs.\n if (existing?.mailboxId && existing.mailboxId !== draft.mailboxId) {\n const stored = mailboxIssues(caller, await loadMailbox(firestore, caller.orgId, existing.mailboxId))\n const refused = stored.find((entry) => entry.code === 'mailbox_not_yours')\n if (refused) return { ok: false, status: 403, code: 'permission', message: refused.message }\n }\n const judged: OutreachSequenceIssue[] = [\n ...validateOutreachSequence(draft),\n ...(await draftPlacementIssues(firestore, caller, draft)),\n ]\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n if (ownership) return { ok: false, status: 403, code: 'permission', message: ownership.message }\n if (existing && (await hasEnrollments(firestore, caller.orgId, existing.id))) {\n judged.push(...outreachEnrolledSequenceIssues(existing, draft))\n }\n const errors = judged.filter((entry) => entry.severity === 'error')\n if (errors.length) {\n return { ok: false, status: 400, code: 'invalid-sequence', message: errors[0].message, issues: judged }\n }\n\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(\n existing?.id ?? input.newId ?? createResourceUid(),\n )\n const sequence: OutreachSequence = {\n id: ref.id,\n ...draft,\n status: existing?.status ?? 'draft',\n /*\n * Carried through explicitly, because the write below REPLACES the\n * document (AGL-3239). The counters are the runtime's and the click\n * route's, never the editor's, and a member renaming a sequence must\n * not reset what it measured.\n */\n ...(existing?.stats ? { stats: existing.stats } : {}),\n createdAtMs: existing?.createdAtMs || nowMs,\n updatedAtMs: nowMs,\n }\n const warnings = judged.filter((entry) => entry.severity === 'warning')\n if (input.judgeOnly) return { ok: true, sequence, created: !existing, warnings }\n /*\n * The list's search fields ride with every write of the name (AGL-3321):\n * the sequences list asks Firestore for them, and a sequence missing\n * them would list normally and never be found.\n */\n await ref.set({ ...sequence, ...nameSearchFields(sequence.name) })\n if (!existing) {\n await deps.logOrgActivity(caller.orgId, { uid: caller.uid, email: caller.email }, OUTREACH_SEQUENCE_ACTIVITY.create, {\n type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,\n id: sequence.id,\n name: sequence.name,\n })\n }\n return { ok: true, sequence, created: !existing, warnings }\n}\n\n/**\n * Deletes a draft nobody was ever enrolled in — the only sequence that may\n * be deleted: what people were sent, and why they stopped, is the record of\n * what the organization did. The delete route and a package import's undo\n * (AGL-3535) both delete through here.\n */\nexport async function removeOutreachSequence(\n deps: Pick<OutreachRouteDeps, 'firestore' | 'logOrgActivity'>,\n caller: Pick<OutreachRouteCaller, 'orgId' | 'uid' | 'email'>,\n sequenceId: string,\n): Promise<{ ok: true } | { ok: false; status: number; code: OutreachRouteRefusalReason; message: string }> {\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return { ok: false, status: 404, code: 'sequence-not-found', message: 'That sequence no longer exists.' }\n if (sequence.status !== 'draft' || (await hasEnrollments(firestore, caller.orgId, sequence.id))) {\n return {\n ok: false,\n status: 409,\n code: 'delete-refused',\n message: 'Only a draft nobody was enrolled in can be deleted. Archive this sequence instead.',\n }\n }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences').doc(sequence.id).delete()\n await deps.logOrgActivity(caller.orgId, { uid: caller.uid, email: caller.email }, OUTREACH_SEQUENCE_ACTIVITY.delete, {\n type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,\n id: sequence.id,\n name: sequence.name,\n })\n return { ok: true }\n}\n\nexport function createOutreachSequenceRoutes(deps: OutreachRouteDeps): OutreachSequenceRoutes {\n const activity = (caller: OutreachRouteCaller, action: string, sequence: Pick<OutreachSequence, 'id' | 'name'>) =>\n deps.logOrgActivity(caller.orgId, { uid: caller.uid, email: caller.email }, action, {\n type: OUTREACH_SEQUENCE_ACTIVITY_TARGET,\n id: sequence.id,\n name: sequence.name,\n })\n\n const save: 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)\n if (caller instanceof Response) return caller\n\n const rawId = body['sequenceId']\n const sequenceId = rawId === undefined || rawId === null || rawId === '' ? null : readOutreachDocumentId(rawId)\n if (rawId && !sequenceId) return outreachRefusal(400, 'invalid-request', 'Name the sequence to save.')\n const outcome = await saveOutreachSequence(deps, caller, {\n sequenceId,\n draft: readOutreachSequenceDraft(body['sequence']),\n })\n if (outcome.ok === false) {\n return outreachRefusal(outcome.status, outcome.code, outcome.message, outcome.issues ? { issues: outcome.issues } : undefined)\n }\n return outreachOk({\n ok: true,\n sequence: outcome.sequence,\n created: outcome.created,\n warnings: outcome.warnings,\n } satisfies OutreachSequenceSaveResponse)\n }\n\n const status: 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)\n if (caller instanceof Response) return caller\n const action = body['action'] as OutreachSequenceAction\n const sequenceId = readOutreachDocumentId(body['sequenceId'])\n if (!sequenceId || !Object.prototype.hasOwnProperty.call(TRANSITIONS, action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the sequence, and activate, pause or archive.')\n }\n const firestore = deps.firestore()\n const sequence = await loadSequence(firestore, caller.orgId, sequenceId)\n if (!sequence) return outreachRefusal(404, 'sequence-not-found', 'That sequence no longer exists.')\n const transition = TRANSITIONS[action]\n if (sequence.status === transition.to) {\n return outreachOk({ ok: true, sequence, stoppedEnrollments: 0 } satisfies OutreachSequenceStatusResponse)\n }\n if (!transition.from.includes(sequence.status)) {\n return outreachRefusal(409, 'transition-refused', ACTION_REFUSALS[action])\n }\n\n if (action === 'activate') {\n const orgSettings = await readOutreachComplianceSettingsDoc(firestore, caller.orgId)\n const judged: OutreachSequenceIssue[] = distinctIssues([\n ...validateOutreachSequenceActivation(sequence, orgSettings),\n ...(await draftPlacementIssues(firestore, caller, sequence, { activating: true })),\n ]).filter((entry) => entry.severity === 'error')\n if (judged.length) {\n const ownership = judged.find((entry) => entry.code === 'mailbox_not_yours')\n return ownership\n ? outreachRefusal(403, 'permission', ownership.message)\n : outreachRefusal(409, 'activation-refused', judged[0].message, { issues: judged })\n }\n }\n\n const nowMs = deps.now()\n const next: OutreachSequence = { ...sequence, status: transition.to, updatedAtMs: nowMs }\n await outreachOrgCollection(firestore, caller.orgId, 'sequences')\n .doc(sequence.id)\n .update({ status: next.status, updatedAtMs: nowMs })\n const stoppedEnrollments =\n action === 'archive' ? await stopOpenEnrollments(firestore, caller, sequence.id, nowMs) : 0\n await activity(caller, OUTREACH_SEQUENCE_ACTIVITY[action], sequence)\n return outreachOk({ ok: true, sequence: next, stoppedEnrollments } satisfies OutreachSequenceStatusResponse)\n }\n\n const remove: 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)\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 delete.')\n const outcome = await removeOutreachSequence(deps, caller, sequenceId)\n if (outcome.ok === false) return outreachRefusal(outcome.status, outcome.code, outcome.message)\n return outreachOk({ ok: true })\n }\n\n return { save, status, remove }\n}\n\n/**\n * Stops everyone still active or paused in an archived sequence, a page at\n * a time, through the engine's own transition. Returns how many it stopped.\n */\nasync function stopOpenEnrollments(\n firestore: Firestore,\n caller: OutreachRouteCaller,\n sequenceId: string,\n nowMs: number,\n): Promise<number> {\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n let stopped = 0\n let after: FirebaseFirestore.QueryDocumentSnapshot | null = null\n for (;;) {\n let page = enrollments.where('sequenceId', '==', sequenceId).orderBy('__name__').limit(ARCHIVE_PAGE)\n if (after) page = page.startAfter(after)\n const snapshot = await page.get()\n if (snapshot.empty) break\n const batch = firestore.batch()\n let writes = 0\n for (const doc of snapshot.docs) {\n const enrollment = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!enrollment || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(enrollment.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(enrollment, {\n type: 'stop',\n reason: 'sequence_archived',\n atMs: nowMs,\n byUid: caller.uid,\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n stopped += writes\n after = snapshot.docs[snapshot.docs.length - 1]\n if (snapshot.size < ARCHIVE_PAGE) break\n }\n return stopped\n}\n"],"names":["normalizeContainerIds","createResourceUid","nameSearchFields","readOrgContainers","applyOutreachEnrollmentEvent","validateOutreachSequence","validateOutreachSequenceActivation","OUTREACH_OPEN_ENROLLMENT_STATUSES","outreachEnrolledSequenceIssues","outreachMailboxActivationIssue","readOutreachSequenceDraft","readOutreachComplianceSettingsDoc","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","readStoredOutreachSequence","OUTREACH_SEQUENCE_ACTIVITY_TARGET","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ARCHIVE_PAGE","OUTREACH_SEQUENCE_ACTIVITY","create","activate","pause","archive","delete","TRANSITIONS","from","to","ACTION_REFUSALS","loadSequence","firestore","orgId","sequenceId","snapshot","doc","get","exists","data","undefined","hasEnrollments","found","where","limit","empty","issue","path","code","message","severity","draftPlacementIssues","caller","draft","options","issues","campaignIds","hostId","host","collection","push","mailbox","mailboxId","loadMailbox","activation","activating","placement","mailboxIssues","filter","entry","some","settings","outside","allowedCountries","includes","length","join","map","campaignId","unknown","index","live","status","connectedByUid","uid","isOrgAdmin","distinctIssues","seen","Set","key","has","add","saveOutreachSequence","deps","input","existing","newId","ok","stored","refused","find","judged","ownership","id","errors","nowMs","now","ref","sequence","stats","createdAtMs","updatedAtMs","warnings","judgeOnly","created","set","name","logOrgActivity","email","type","removeOutreachSequence","createOutreachSequenceRoutes","activity","action","save","request","method","body","gate","Response","rawId","outcome","Object","prototype","hasOwnProperty","call","transition","stoppedEnrollments","orgSettings","next","update","stopOpenEnrollments","remove","enrollments","stopped","after","page","orderBy","startAfter","batch","writes","docs","enrollment","patch","reason","atMs","byUid","commit","size"],"mappings":"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"}
|
|
@@ -14,7 +14,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
|
14
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
15
|
* See the License for the specific language governing permissions and
|
|
16
16
|
* limitations under the License.
|
|
17
|
-
*/ import {
|
|
17
|
+
*/ import { SEND_LINK_ROLLUP_MAX, sendLinkKey } from "@aglyn/shared-ui-email-campaigns/model/send-report";
|
|
18
18
|
import { FieldValue } from "firebase-admin/firestore";
|
|
19
19
|
import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
|
|
20
20
|
import { judgeOutreachClick } from "../engine/click-tracking.js";
|
|
@@ -81,7 +81,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
81
81
|
});
|
|
82
82
|
const held = readOutreachEngagement(enrollment.engagement);
|
|
83
83
|
const first = judgement.human && held.firstClickAtMs === null;
|
|
84
|
-
const key =
|
|
84
|
+
const key = sendLinkKey(url);
|
|
85
85
|
// A row for this visit, while the person's history has room for one.
|
|
86
86
|
const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX;
|
|
87
87
|
/*
|
|
@@ -190,16 +190,16 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
190
190
|
/**
|
|
191
191
|
* The per-destination rollup for one sequence.
|
|
192
192
|
*
|
|
193
|
-
* Keyed by `
|
|
193
|
+
* Keyed by `sendLinkKey`, which drops the query string — a step body
|
|
194
194
|
* goes through the merge resolver per recipient, so keying on the full URL
|
|
195
195
|
* would mint one row per PERSON and turn the aggregate into the
|
|
196
196
|
* per-recipient list it exists to summarise. Capped at
|
|
197
|
-
* `
|
|
197
|
+
* `SEND_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it
|
|
198
198
|
* is one document with a size ceiling. Past the cap a click is counted in
|
|
199
199
|
* `overflowClicks` rather than dropped, so the table still reconciles.
|
|
200
200
|
*/ async function bumpLinkRollup(firestore, input) {
|
|
201
201
|
if (!input.sequenceId) return;
|
|
202
|
-
const key =
|
|
202
|
+
const key = sendLinkKey(input.url);
|
|
203
203
|
const ref = outreachOrgCollection(firestore, input.orgId, 'sequences').doc(input.sequenceId).collection(OUTREACH_LINK_ROLLUP_PATH[0]).doc(OUTREACH_LINK_ROLLUP_PATH[1]);
|
|
204
204
|
if (!key) {
|
|
205
205
|
await ref.set({
|
|
@@ -215,7 +215,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
215
215
|
const snapshot = await transaction.get(ref);
|
|
216
216
|
const links = snapshot.exists ? (_snapshot_data = snapshot.data()) == null ? void 0 : _snapshot_data['links'] : null;
|
|
217
217
|
const held = links == null ? void 0 : links[campaignRollupField(key)];
|
|
218
|
-
if (!held && Object.keys(links != null ? links : {}).length >=
|
|
218
|
+
if (!held && Object.keys(links != null ? links : {}).length >= SEND_LINK_ROLLUP_MAX) {
|
|
219
219
|
transaction.set(ref, {
|
|
220
220
|
overflowClicks: FieldValue.increment(1)
|
|
221
221
|
}, {
|
|
@@ -258,7 +258,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
258
258
|
const writer = deps.timeline();
|
|
259
259
|
if (!writer) return;
|
|
260
260
|
try {
|
|
261
|
-
var
|
|
261
|
+
var _sendLinkKey;
|
|
262
262
|
const written = await writer.logActivity({
|
|
263
263
|
orgId: input.orgId,
|
|
264
264
|
hostId: input.enrollment.hostId,
|
|
@@ -266,7 +266,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
|
|
|
266
266
|
sourcePluginId: OUTREACH_PLUGIN_ID,
|
|
267
267
|
kind: 'note',
|
|
268
268
|
atMs: input.nowMs,
|
|
269
|
-
body: `Followed a link in a sequence email: ${(
|
|
269
|
+
body: `Followed a link in a sequence email: ${(_sendLinkKey = sendLinkKey(input.url)) != null ? _sendLinkKey : input.url}`,
|
|
270
270
|
byUid: '',
|
|
271
271
|
byName: null
|
|
272
272
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-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 {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachStepSentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = campaignLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-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 {\n SEND_LINK_ROLLUP_MAX,\n sendLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { outreachLoggedEngagementRows, readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried a link or an image was sent, or `null` when it cannot be found. */\nexport function outreachStepSentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = outreachStepSentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = sendLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = outreachLoggedEngagementRows(held) < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n ...held,\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `sendLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `SEND_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = sendLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= SEND_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${sendLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["SEND_LINK_ROLLUP_MAX","sendLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","outreachLoggedEngagementRows","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","outreachStepSentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","loggedClicks","loggedMachineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
1
2
|
/**
|
|
2
3
|
* @license
|
|
3
4
|
* Copyright 2026 Aglyn LLC
|
|
@@ -13,7 +14,7 @@
|
|
|
13
14
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
15
|
* See the License for the specific language governing permissions and
|
|
15
16
|
* limitations under the License.
|
|
16
|
-
*/ import {
|
|
17
|
+
*/ import { readClientIp } from "@aglyn/aglyn/app-utils/request-ip";
|
|
17
18
|
import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
|
|
18
19
|
import { recordOutreachSequenceTouch } from "./campaign-credit.js";
|
|
19
20
|
import { isOutreachLinkId, isOutreachStoredOpen, readOutreachClickToken, readOutreachStoredLink, readOutreachStoredOpen } from "./click-link.js";
|
|
@@ -145,7 +146,9 @@ const brokenLink = ()=>outreachUnsubscribePage({
|
|
|
145
146
|
await recordOutreachOpen(deps, {
|
|
146
147
|
target,
|
|
147
148
|
method: request.method,
|
|
148
|
-
userAgent: request.headers.get('user-agent')
|
|
149
|
+
userAgent: request.headers.get('user-agent'),
|
|
150
|
+
// Read for the network it belongs to (AGL-3488), and never stored.
|
|
151
|
+
address: readClientIp(request.headers)
|
|
149
152
|
});
|
|
150
153
|
} catch (error) {
|
|
151
154
|
console.error('[outreach] an open could not be recorded', error);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport {\n isOutreachLinkId,\n isOutreachStoredOpen,\n readOutreachClickToken,\n readOutreachStoredLink,\n readOutreachStoredOpen,\n type OutreachClickTarget,\n type OutreachOpenTarget,\n} from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport { recordOutreachOpen } from './open-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short\n * `GET /api/outreach/l/<id>` every send carries now, and the signed\n * `GET /api/outreach/click?t=…` the emails sent before it carry.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The signed link carries its destination and needs no read; the short one\n * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\ntype ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>\n\nconst methodNotAllowed = () =>\n new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n\nconst brokenLink = () =>\n outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n\n/**\n * The redirect, then the recording — the half both routes share once they\n * know where the link goes.\n */\nasync function followOutreachClick(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachClickTarget,\n): Promise<Response> {\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — both\n * readers return nothing else — so it is a header value that cannot carry\n * a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n // A link in a test of a step (AGL-3325) is followed and never counted:\n // the member clicking their own test is not a recipient acting.\n if (target.test) return redirect\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n}\n\n/**\n * A transparent 1×1 GIF: the tracking image (AGL-3395). The smallest image\n * every mail client renders, and the one that draws nothing if it is shown.\n */\nconst PIXEL_GIF = Uint8Array.from(\n Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'),\n)\n\n/**\n * The image, then the recording (AGL-3395).\n *\n * Every fetch gets the same image — a person, a mail privacy proxy and a\n * gateway alike, and an image whose document is unreadable too — because a\n * broken image in a one-to-one email is a visible fault and a missing open\n * is only a number. `no-store` asks every cache not to keep it, so a later\n * open fetches again; a proxy that caches anyway (Gmail's does) is why the\n * first open is the one the rate is taken over.\n */\nasync function answerOutreachOpen(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachOpenTarget | null,\n): Promise<Response> {\n const image = new Response(request.method === 'HEAD' ? null : PIXEL_GIF, {\n status: 200,\n headers: {\n 'Content-Type': 'image/gif',\n 'Content-Length': String(PIXEL_GIF.byteLength),\n 'Cache-Control': 'no-store, no-cache, must-revalidate, private',\n 'X-Robots-Tag': 'noindex, nofollow',\n },\n })\n if (!target || target.test) return image\n try {\n await recordOutreachOpen(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n } catch (error) {\n console.error('[outreach] an open could not be recorded', error)\n }\n return image\n}\n\n/** The signed link, `?t=…` — every tracked email sent before AGL-3297. */\nexport function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n\n/**\n * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.\n *\n * The same route answers a tracking image (AGL-3395) — an `outreachLinks`\n * document of kind `open` — with the image instead of a redirect, so the\n * image lives on the same host and path as every link.\n *\n * One read of `outreachLinks/<id>` stands where the signature check stood,\n * and everything after it is the signed route's: the same redirect, the same\n * headers, the same recording, scanners redirected and counted apart.\n *\n * The destination comes only from that document. An id that is malformed or\n * names nothing is answered with the plain page, never followed; a read that\n * FAILS is answered with a page saying to try again, because there is\n * nowhere honest to send them either.\n */\nexport function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n // The id is the last path segment: `/api/outreach/l/<id>`.\n const linkId = new URL(request.url).pathname.split('/').pop()\n if (!isOutreachLinkId(linkId)) return brokenLink()\n let data: unknown\n try {\n const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get()\n data = snapshot.exists ? snapshot.data() : undefined\n } catch (error) {\n console.error('[outreach] a short link could not be read', error)\n return outreachUnsubscribePage(\n {\n title: 'This link isn’t working right now',\n body: 'Something went wrong on our side. Try the link again in a minute.',\n },\n 503,\n )\n }\n // The id names a tracking image (AGL-3395): the image, not a redirect.\n if (isOutreachStoredOpen(data)) return answerOutreachOpen(deps, request, readOutreachStoredOpen(data))\n const target = readOutreachStoredLink(data)\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n"],"names":["OUTREACH_COLLECTIONS","recordOutreachSequenceTouch","isOutreachLinkId","isOutreachStoredOpen","readOutreachClickToken","readOutreachStoredLink","readOutreachStoredOpen","recordOutreachClick","recordOutreachOpen","outreachUnsubscribePage","REDIRECT_HEADERS","methodNotAllowed","Response","status","headers","Allow","brokenLink","title","body","followOutreachClick","deps","request","target","redirect","Location","url","test","outcome","method","userAgent","get","human","enrollment","atMs","now","error","console","PIXEL_GIF","Uint8Array","from","Buffer","answerOutreachOpen","image","String","byteLength","createOutreachClickRoute","URL","searchParams","createOutreachShortLinkRoute","linkId","pathname","split","pop","data","snapshot","firestore","collection","links","doc","exists","undefined"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.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 { readClientIp } from '@aglyn/aglyn/app-utils/request-ip'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { OUTREACH_COLLECTIONS } from '../model/outreach.types'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport {\n isOutreachLinkId,\n isOutreachStoredOpen,\n readOutreachClickToken,\n readOutreachStoredLink,\n readOutreachStoredOpen,\n type OutreachClickTarget,\n type OutreachOpenTarget,\n} from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport { recordOutreachOpen } from './open-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239, AGL-3297): the short\n * `GET /api/outreach/l/<id>` every send carries now, and the signed\n * `GET /api/outreach/click?t=…` the emails sent before it carry.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The signed link carries its destination and needs no read; the short one\n * needs exactly one, of the document that names it. Then the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\ntype ClickRouteDeps = Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>\n\nconst methodNotAllowed = () =>\n new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n\nconst brokenLink = () =>\n outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n\n/**\n * The redirect, then the recording — the half both routes share once they\n * know where the link goes.\n */\nasync function followOutreachClick(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachClickTarget,\n): Promise<Response> {\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — both\n * readers return nothing else — so it is a header value that cannot carry\n * a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n // A link in a test of a step (AGL-3325) is followed and never counted:\n // the member clicking their own test is not a recipient acting.\n if (target.test) return redirect\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n}\n\n/**\n * A transparent 1×1 GIF: the tracking image (AGL-3395). The smallest image\n * every mail client renders, and the one that draws nothing if it is shown.\n */\nconst PIXEL_GIF = Uint8Array.from(\n Buffer.from('R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7', 'base64'),\n)\n\n/**\n * The image, then the recording (AGL-3395).\n *\n * Every fetch gets the same image — a person, a mail privacy proxy and a\n * gateway alike, and an image whose document is unreadable too — because a\n * broken image in a one-to-one email is a visible fault and a missing open\n * is only a number. `no-store` asks every cache not to keep it, so a later\n * open fetches again; a proxy that caches anyway (Gmail's does) is why the\n * first open is the one the rate is taken over.\n */\nasync function answerOutreachOpen(\n deps: ClickRouteDeps,\n request: Request,\n target: OutreachOpenTarget | null,\n): Promise<Response> {\n const image = new Response(request.method === 'HEAD' ? null : PIXEL_GIF, {\n status: 200,\n headers: {\n 'Content-Type': 'image/gif',\n 'Content-Length': String(PIXEL_GIF.byteLength),\n 'Cache-Control': 'no-store, no-cache, must-revalidate, private',\n 'X-Robots-Tag': 'noindex, nofollow',\n },\n })\n if (!target || target.test) return image\n try {\n await recordOutreachOpen(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n // Read for the network it belongs to (AGL-3488), and never stored.\n address: readClientIp(request.headers),\n })\n } catch (error) {\n console.error('[outreach] an open could not be recorded', error)\n }\n return image\n}\n\n/** The signed link, `?t=…` — every tracked email sent before AGL-3297. */\nexport function createOutreachClickRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n\n/**\n * THE SHORT LINK (AGL-3297): `GET /api/outreach/l/<id>`.\n *\n * The same route answers a tracking image (AGL-3395) — an `outreachLinks`\n * document of kind `open` — with the image instead of a redirect, so the\n * image lives on the same host and path as every link.\n *\n * One read of `outreachLinks/<id>` stands where the signature check stood,\n * and everything after it is the signed route's: the same redirect, the same\n * headers, the same recording, scanners redirected and counted apart.\n *\n * The destination comes only from that document. An id that is malformed or\n * names nothing is answered with the plain page, never followed; a read that\n * FAILS is answered with a page saying to try again, because there is\n * nowhere honest to send them either.\n */\nexport function createOutreachShortLinkRoute(deps: ClickRouteDeps): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'GET' && request.method !== 'HEAD') return methodNotAllowed()\n // The id is the last path segment: `/api/outreach/l/<id>`.\n const linkId = new URL(request.url).pathname.split('/').pop()\n if (!isOutreachLinkId(linkId)) return brokenLink()\n let data: unknown\n try {\n const snapshot = await deps.firestore().collection(OUTREACH_COLLECTIONS.links).doc(linkId).get()\n data = snapshot.exists ? snapshot.data() : undefined\n } catch (error) {\n console.error('[outreach] a short link could not be read', error)\n return outreachUnsubscribePage(\n {\n title: 'This link isn’t working right now',\n body: 'Something went wrong on our side. Try the link again in a minute.',\n },\n 503,\n )\n }\n // The id names a tracking image (AGL-3395): the image, not a redirect.\n if (isOutreachStoredOpen(data)) return answerOutreachOpen(deps, request, readOutreachStoredOpen(data))\n const target = readOutreachStoredLink(data)\n if (!target) return brokenLink()\n return followOutreachClick(deps, request, target)\n }\n}\n"],"names":["readClientIp","OUTREACH_COLLECTIONS","recordOutreachSequenceTouch","isOutreachLinkId","isOutreachStoredOpen","readOutreachClickToken","readOutreachStoredLink","readOutreachStoredOpen","recordOutreachClick","recordOutreachOpen","outreachUnsubscribePage","REDIRECT_HEADERS","methodNotAllowed","Response","status","headers","Allow","brokenLink","title","body","followOutreachClick","deps","request","target","redirect","Location","url","test","outcome","method","userAgent","get","human","enrollment","atMs","now","error","console","PIXEL_GIF","Uint8Array","from","Buffer","answerOutreachOpen","image","String","byteLength","address","createOutreachClickRoute","URL","searchParams","createOutreachShortLinkRoute","linkId","pathname","split","pop","data","snapshot","firestore","collection","links","doc","exists","undefined"],"mappings":"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"}
|
|
@@ -14,18 +14,26 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/
|
|
17
|
+
/**
|
|
18
|
+
* WHAT A SEQUENCE DOES WHEN A LEAD BECOMES A CONTACT (AGL-3234).
|
|
19
|
+
*
|
|
20
|
+
* A CONVERSION re-points every enrollment made on the lead at the contact it
|
|
21
|
+
* became: the record system tells every plugin through its lead-conversion
|
|
22
|
+
* seam, and this is Sequences' share of it. The enrollment keeps its id, its
|
|
23
|
+
* thread and its place in the steps, and keeps naming the lead beside the
|
|
24
|
+
* contact, so both pages list it.
|
|
25
|
+
*
|
|
26
|
+
* What a sequence does to the lead's OWN record — a sent step moving a New
|
|
27
|
+
* lead to Nurturing, a reply moving it to Working — is the record system's
|
|
28
|
+
* to write, and Sequences tells it through the record email-state seam
|
|
29
|
+
* (`stampRecordEmailReach`, `stampRecordEmailReply`, AGL-3080) rather than
|
|
30
|
+
* writing the lead here.
|
|
31
|
+
*
|
|
32
|
+
* Never throws for a missing enrollment: one that could not follow its lead
|
|
33
|
+
* is one the next send stops as `contact_missing` and a rep re-enrolls.
|
|
34
|
+
*/
|
|
17
35
|
import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
|
|
18
36
|
type Firestore = FirebaseFirestore.Firestore;
|
|
19
|
-
/** A lead nobody has touched, once a step was sent to it: Nurturing. `true` when it moved. */
|
|
20
|
-
export declare function markOutreachLeadNurturing(firestore: Firestore, input: {
|
|
21
|
-
orgId: string;
|
|
22
|
-
leadId: string;
|
|
23
|
-
}): Promise<boolean>;
|
|
24
|
-
/** A lead no person has engaged, once somebody replied: Working. `true` when it moved. */
|
|
25
|
-
export declare function markOutreachLeadWorking(firestore: Firestore, input: {
|
|
26
|
-
orgId: string;
|
|
27
|
-
leadId: string;
|
|
28
|
-
}): Promise<boolean>;
|
|
29
37
|
/** Every enrollment made on the lead now names the contact it became. */
|
|
30
38
|
export declare function followOutreachLeadToContact(firestore: Firestore, input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>): Promise<PluginLeadConversionReport>;
|
|
31
39
|
export {};
|
|
@@ -14,70 +14,24 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ /**
|
|
17
|
-
* WHAT A SEQUENCE DOES
|
|
17
|
+
* WHAT A SEQUENCE DOES WHEN A LEAD BECOMES A CONTACT (AGL-3234).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* A CONVERSION re-points every enrollment made on the lead at the contact it
|
|
20
|
+
* became: the record system tells every plugin through its lead-conversion
|
|
21
|
+
* seam, and this is Sequences' share of it. The enrollment keeps its id, its
|
|
22
|
+
* thread and its place in the steps, and keeps naming the lead beside the
|
|
23
|
+
* contact, so both pages list it.
|
|
22
24
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* - a REPLY moves a lead that is New or Nurturing to Working. Somebody
|
|
29
|
-
* wrote back; the lead is being worked, and a queue that still showed it
|
|
30
|
-
* as New would send a rep to open a conversation already open. A lead
|
|
31
|
-
* already Working, closed, or converted is left as it is — a reply does
|
|
32
|
-
* not reopen a verdict or move a contact's record.
|
|
33
|
-
* - a CONVERSION re-points every enrollment made on the lead at the contact
|
|
34
|
-
* it became: the record system tells every plugin through its
|
|
35
|
-
* lead-conversion seam, and this is Sequences' share of it. The
|
|
36
|
-
* enrollment keeps its id, its thread and its place in the steps, and
|
|
37
|
-
* keeps naming the lead beside the contact, so both pages list it.
|
|
25
|
+
* What a sequence does to the lead's OWN record — a sent step moving a New
|
|
26
|
+
* lead to Nurturing, a reply moving it to Working — is the record system's
|
|
27
|
+
* to write, and Sequences tells it through the record email-state seam
|
|
28
|
+
* (`stampRecordEmailReach`, `stampRecordEmailReply`, AGL-3080) rather than
|
|
29
|
+
* writing the lead here.
|
|
38
30
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
|
|
42
|
-
*/ import { crmLeadStatus } from "@aglyn/aglyn/app-utils/crm";
|
|
43
|
-
import { FieldValue } from "firebase-admin/firestore";
|
|
44
|
-
import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
31
|
+
* Never throws for a missing enrollment: one that could not follow its lead
|
|
32
|
+
* is one the next send stops as `contact_missing` and a rep re-enrolls.
|
|
33
|
+
*/ import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
45
34
|
/** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
|
|
46
|
-
/**
|
|
47
|
-
* Move a lead forward to `to` when it stands in one of `from`; `true` when
|
|
48
|
-
* it moved. A converted lead is a contact's history and never moves.
|
|
49
|
-
*/ async function advanceOutreachLead(firestore, input, from, to) {
|
|
50
|
-
try {
|
|
51
|
-
var _snapshot_data;
|
|
52
|
-
// A lead is one org row (AGL-3275), so the site no longer names it.
|
|
53
|
-
const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId);
|
|
54
|
-
const snapshot = await ref.get();
|
|
55
|
-
if (!snapshot.exists) return false;
|
|
56
|
-
const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
|
|
57
|
-
if (lead['convertedContactId'] || !from.includes(crmLeadStatus(lead))) return false;
|
|
58
|
-
await ref.set({
|
|
59
|
-
status: to,
|
|
60
|
-
updatedAt: FieldValue.serverTimestamp()
|
|
61
|
-
}, {
|
|
62
|
-
merge: true
|
|
63
|
-
});
|
|
64
|
-
return true;
|
|
65
|
-
} catch (error) {
|
|
66
|
-
console.error(`[outreach] the lead could not be marked ${to}`, input.orgId, input.leadId, error);
|
|
67
|
-
return false;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
/** A lead nobody has touched, once a step was sent to it: Nurturing. `true` when it moved. */ export function markOutreachLeadNurturing(firestore, input) {
|
|
71
|
-
return advanceOutreachLead(firestore, input, [
|
|
72
|
-
'new'
|
|
73
|
-
], 'nurturing');
|
|
74
|
-
}
|
|
75
|
-
/** A lead no person has engaged, once somebody replied: Working. `true` when it moved. */ export function markOutreachLeadWorking(firestore, input) {
|
|
76
|
-
return advanceOutreachLead(firestore, input, [
|
|
77
|
-
'new',
|
|
78
|
-
'nurturing'
|
|
79
|
-
], 'working');
|
|
80
|
-
}
|
|
81
35
|
/** Every enrollment made on the lead now names the contact it became. */ export async function followOutreachLeadToContact(firestore, input) {
|
|
82
36
|
const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments');
|
|
83
37
|
const rows = await enrollments.where('leadId', '==', input.leadId).get();
|