@aglyn/plugins-outreach 1.0.0-beta.218 → 1.0.0-beta.220

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/package.json +15 -15
  2. package/src/lib/components/compliance-section.d.ts +5 -0
  3. package/src/lib/components/compliance-section.js +302 -180
  4. package/src/lib/components/compliance-section.js.map +1 -1
  5. package/src/lib/components/enroll-dialog.d.ts +12 -2
  6. package/src/lib/components/enroll-dialog.js +72 -18
  7. package/src/lib/components/enroll-dialog.js.map +1 -1
  8. package/src/lib/components/enrollment-detail.d.ts +5 -1
  9. package/src/lib/components/enrollment-detail.js +14 -8
  10. package/src/lib/components/enrollment-detail.js.map +1 -1
  11. package/src/lib/components/outreach-console-sections.d.ts +1 -1
  12. package/src/lib/components/outreach-console-sections.js +1 -1
  13. package/src/lib/components/outreach-console-sections.js.map +1 -1
  14. package/src/lib/components/sequence-editor.d.ts +13 -0
  15. package/src/lib/components/sequence-editor.js +586 -335
  16. package/src/lib/components/sequence-editor.js.map +1 -1
  17. package/src/lib/components/sequence-preview.js +25 -10
  18. package/src/lib/components/sequence-preview.js.map +1 -1
  19. package/src/lib/components/sequence-step-card.d.ts +10 -0
  20. package/src/lib/components/sequence-step-card.js +320 -202
  21. package/src/lib/components/sequence-step-card.js.map +1 -1
  22. package/src/lib/components/use-outreach-api.d.ts +2 -1
  23. package/src/lib/components/use-outreach-api.js +5 -3
  24. package/src/lib/components/use-outreach-api.js.map +1 -1
  25. package/src/lib/engine/compose.d.ts +1 -1
  26. package/src/lib/engine/compose.js +10 -6
  27. package/src/lib/engine/compose.js.map +1 -1
  28. package/src/lib/engine/delivery-status.js +1 -1
  29. package/src/lib/engine/delivery-status.js.map +1 -1
  30. package/src/lib/engine/enrollment-activity.d.ts +9 -1
  31. package/src/lib/engine/enrollment-activity.js +15 -2
  32. package/src/lib/engine/enrollment-activity.js.map +1 -1
  33. package/src/lib/engine/enrollment-state.d.ts +33 -2
  34. package/src/lib/engine/enrollment-state.js +68 -9
  35. package/src/lib/engine/enrollment-state.js.map +1 -1
  36. package/src/lib/engine/gates.d.ts +2 -2
  37. package/src/lib/engine/gates.js.map +1 -1
  38. package/src/lib/engine/opt-out-intent.js +1 -1
  39. package/src/lib/engine/opt-out-intent.js.map +1 -1
  40. package/src/lib/engine/sending-capacity.d.ts +1 -1
  41. package/src/lib/engine/sending-capacity.js +1 -1
  42. package/src/lib/engine/sending-capacity.js.map +1 -1
  43. package/src/lib/engine/sequence-validation.d.ts +20 -3
  44. package/src/lib/engine/sequence-validation.js +32 -6
  45. package/src/lib/engine/sequence-validation.js.map +1 -1
  46. package/src/lib/engine/thread-classification.js +3 -3
  47. package/src/lib/engine/thread-classification.js.map +1 -1
  48. package/src/lib/enrollment/enroll-people.js +6 -4
  49. package/src/lib/enrollment/enroll-people.js.map +1 -1
  50. package/src/lib/enrollment/gate-lookups.js +5 -4
  51. package/src/lib/enrollment/gate-lookups.js.map +1 -1
  52. package/src/lib/enrollment/step-overrides.d.ts +4 -2
  53. package/src/lib/enrollment/step-overrides.js +11 -4
  54. package/src/lib/enrollment/step-overrides.js.map +1 -1
  55. package/src/lib/mailboxes/mailbox-api.d.ts +5 -1
  56. package/src/lib/mailboxes/mailbox-api.js.map +1 -1
  57. package/src/lib/mailboxes/mailbox-routes.d.ts +5 -2
  58. package/src/lib/mailboxes/mailbox-routes.js +10 -7
  59. package/src/lib/mailboxes/mailbox-routes.js.map +1 -1
  60. package/src/lib/mailboxes/mailbox-settings.d.ts +2 -2
  61. package/src/lib/mailboxes/mailbox-settings.js +1 -1
  62. package/src/lib/mailboxes/mailbox-settings.js.map +1 -1
  63. package/src/lib/model/enrollment-timeline.d.ts +1 -1
  64. package/src/lib/model/enrollment-timeline.js +22 -3
  65. package/src/lib/model/enrollment-timeline.js.map +1 -1
  66. package/src/lib/model/outreach-api.d.ts +19 -1
  67. package/src/lib/model/outreach-api.js.map +1 -1
  68. package/src/lib/model/outreach.types.d.ts +26 -1
  69. package/src/lib/model/outreach.types.js.map +1 -1
  70. package/src/lib/model/sales-topic.d.ts +27 -0
  71. package/src/lib/model/sales-topic.js +27 -0
  72. package/src/lib/model/sales-topic.js.map +1 -0
  73. package/src/lib/model/sequence-draft.d.ts +1 -1
  74. package/src/lib/model/sequence-draft.js +2 -2
  75. package/src/lib/model/sequence-draft.js.map +1 -1
  76. package/src/lib/model/step-preview.d.ts +2 -0
  77. package/src/lib/model/step-preview.js +11 -7
  78. package/src/lib/model/step-preview.js.map +1 -1
  79. package/src/lib/model/stored-records.js +28 -2
  80. package/src/lib/model/stored-records.js.map +1 -1
  81. package/src/lib/plugin.js +1 -1
  82. package/src/lib/plugin.js.map +1 -1
  83. package/src/lib/routes/curate-routes.js +7 -7
  84. package/src/lib/routes/curate-routes.js.map +1 -1
  85. package/src/lib/routes/enroll-routes.d.ts +6 -0
  86. package/src/lib/routes/enroll-routes.js +31 -19
  87. package/src/lib/routes/enroll-routes.js.map +1 -1
  88. package/src/lib/routes/preview-routes.js +14 -5
  89. package/src/lib/routes/preview-routes.js.map +1 -1
  90. package/src/lib/routes/sequence-routes.js +9 -9
  91. package/src/lib/routes/sequence-routes.js.map +1 -1
  92. package/src/lib/routes/step-test-routes.js +3 -3
  93. package/src/lib/routes/step-test-routes.js.map +1 -1
  94. package/src/lib/runtime/campaign-credit.js +2 -2
  95. package/src/lib/runtime/campaign-credit.js.map +1 -1
  96. package/src/lib/runtime/campaign-detach.js +3 -2
  97. package/src/lib/runtime/campaign-detach.js.map +1 -1
  98. package/src/lib/runtime/lead-records.d.ts +6 -1
  99. package/src/lib/runtime/lead-records.js +25 -6
  100. package/src/lib/runtime/lead-records.js.map +1 -1
  101. package/src/lib/runtime/platform-runtime-deps.js +3 -3
  102. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  103. package/src/lib/runtime/send-job.js +20 -4
  104. package/src/lib/runtime/send-job.js.map +1 -1
  105. package/src/lib/runtime/sync-job.js +3 -3
  106. package/src/lib/runtime/sync-job.js.map +1 -1
  107. package/src/lib/testing/stand-in-crm-record-routes.d.ts +25 -0
  108. package/src/lib/testing/stand-in-crm-record-routes.js +46 -0
  109. package/src/lib/testing/stand-in-crm-record-routes.js.map +1 -0
  110. package/src/lib/transport/gmail-errors.d.ts +27 -0
  111. package/src/lib/transport/gmail-errors.js +56 -0
  112. package/src/lib/transport/gmail-errors.js.map +1 -1
@@ -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 { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-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 { 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 = normalizeCampaignIds(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\n * (`orgs/{orgId}/emailCampaigns`, 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 containers = firestore.collection('orgs').doc(caller.orgId).collection('emailCampaigns')\n const found = await firestore.getAll(\n ...campaignIds.map((campaignId) => containers.doc(readOutreachDocumentId(campaignId) ?? '-')),\n )\n const unknown = campaignIds.filter((campaignId, index) => {\n const snapshot = found[index]\n return !readOutreachDocumentId(campaignId) || !snapshot?.exists || Boolean(snapshot.get('deletedAt'))\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":["normalizeCampaignIds","createResourceUid","nameSearchFields","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","containers","getAll","map","campaignId","unknown","index","Boolean","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 {\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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"}
@@ -23,7 +23,7 @@ import { OUTREACH_COLLECTIONS } from "../model/outreach.types.js";
23
23
  import { previewOutreachStep } from "../model/step-preview.js";
24
24
  import { newOutreachLinkId, OUTREACH_TEST_LINK_ENROLLMENT, outreachStoredLink, outreachStoredOpen } from "../runtime/click-link.js";
25
25
  import { readStoredOutreachMailbox } from "../storage/outreach-records.js";
26
- import { GmailTransportError } from "../transport/gmail-errors.js";
26
+ import { GmailTransportError, googleFailureAnswer } from "../transport/gmail-errors.js";
27
27
  import { Rfc5322MessageError } from "../transport/rfc5322.js";
28
28
  import { sendComposedOutreachEmail } from "../transport/send-message.js";
29
29
  import { readOutreachStepRender } from "./preview-routes.js";
@@ -251,8 +251,8 @@ export function createOutreachStepTestRoute(deps, testDeps) {
251
251
  if (error instanceof Rfc5322MessageError) {
252
252
  return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`);
253
253
  }
254
- if (!(error instanceof GmailTransportError)) throw error;
255
- return outreachRefusal(502, 'google-unavailable', error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`);
254
+ const answer = googleFailureAnswer(error, 'step test send');
255
+ return outreachRefusal(answer.status, answer.reason, answer.error);
256
256
  }
257
257
  const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs);
258
258
  return outreachOk({
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n outreachStoredOpen,\n type OutreachStoredLink,\n type OutreachStoredOpen,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if ((sequence.settings.trackClicks || sequence.settings.countOpens) && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n /*\n * The tracking image (AGL-3395), as a real send would carry it and\n * marked as a test's: the member sees both parts a recipient gets, and\n * their own open is recorded nowhere.\n */\n let pixel: { id: string; doc: OutreachStoredOpen; url: string } | null = null\n if (sequence.settings.countOpens) {\n const doc = outreachStoredOpen(\n { orgId: caller.orgId, enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT, stepIndex, test: true },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (doc && url) pixel = { id, doc: { ...doc, sequenceId: sequence.id }, url }\n }\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink, openPixelUrl: pixel?.url ?? null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length || pixel) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n if (pixel) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(pixel.id), pixel.doc)\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links, as plain text.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n ...(composed.email.html ? { html: composed.email.html } : {}),\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n if (!(error instanceof GmailTransportError)) throw error\n return outreachRefusal(\n 502,\n 'google-unavailable',\n error.retryable ? 'Google did not answer. Try again in a moment.' : `Google refused the request: ${error.message}`,\n )\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","outreachStoredOpen","readStoredOutreachMailbox","GmailTransportError","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","countOpens","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","pixel","composed","input","openPixelUrl","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","html","trackedLinks","client","reconnectRequired","code","retryable","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/step-test-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 { normalizeContactEmail } from '@aglyn/aglyn/app-utils/contacts'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { ComposedOutreachEmail } from '../engine/compose'\nimport { hasOutreachValidationErrors, validateOutreachSteps } from '../engine/sequence-validation'\nimport { mailboxRef } from '../mailboxes/mailbox-credentials'\nimport { outreachLocalDay } from '../mailboxes/mailbox-settings'\nimport {\n markOutreachMailboxReconnectRequired,\n type OpenedOutreachMailbox,\n} from '../mailboxes/mailbox-transport'\nimport type { OutreachStepTestResponse } from '../model/outreach-api'\nimport { OUTREACH_COLLECTIONS, type OutreachMailbox } from '../model/outreach.types'\nimport { previewOutreachStep } from '../model/step-preview'\nimport {\n newOutreachLinkId,\n OUTREACH_TEST_LINK_ENROLLMENT,\n outreachStoredLink,\n outreachStoredOpen,\n type OutreachStoredLink,\n type OutreachStoredOpen,\n} from '../runtime/click-link'\nimport { readStoredOutreachMailbox } from '../storage/outreach-records'\nimport { GmailTransportError, googleFailureAnswer } from '../transport/gmail-errors'\nimport { Rfc5322MessageError } from '../transport/rfc5322'\nimport { sendComposedOutreachEmail } from '../transport/send-message'\nimport { readOutreachStepRender } from './preview-routes'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachJsonBody } from './route-http'\n\n/**\n * A TEST OF ONE STEP, IN A REAL INBOX (AGL-3325): `outreach/steps/test`.\n *\n * The Mailboxes page's test proves a mailbox can send; this proves what a\n * step LOOKS like once it has been through Gmail. It is the preview's email\n * — the same reader, the same person or sample person, the same personal\n * line, the same footer — sent through the sequence's mailbox to the member\n * who asked, or to an address they typed, with `[Test] ` in front of the\n * subject so it never reads as real mail.\n *\n * ## Rendered as a send, sent as a test\n *\n * A later step in a thread is written as `Re:` the thread's subject, as the\n * preview writes it, but sent as a new message: there is no thread to\n * answer, and the preview's placeholder message id must not leave the\n * server. When the sequence counts clicks, every link is rewritten to a\n * short link as a real send's would be, so the member sees the link their\n * recipients will see — but each is stored as a TEST link, which the click\n * route follows and never records (`click-link.ts`). A member clicking their\n * own test must not move the sequence's `clicks` or `machineClicks`.\n *\n * Merge fields the person has nothing for are sent empty and named in the\n * answer, where a real send would pause: the point of a test is to see the\n * gap, not to be refused it.\n *\n * ## Counted on nothing but the mailbox's tests\n *\n * A test is a real Gmail send and spends the account's own limits. It is\n * not a person emailed: it moves neither the sequence's `stats` nor the\n * mailbox's `health.daily.<day>.sent`, it reserves nothing of the daily cap,\n * it files nothing on anyone's record, and it enrolls nobody. It is tallied\n * on `health.daily.<day>.tests`, which the Mailboxes card shows as \"Tests\n * today\", and held to {@link OUTREACH_STEP_TESTS_PER_HOUR} a mailbox.\n *\n * ## Who, and from what\n *\n * The mailbox's own member, or an organization owner or admin — the people\n * who may edit and activate the sequence. A paused mailbox, or one waiting\n * to be reconnected, refuses and says which; a member who cannot send is\n * told where to fix it rather than shown a Google error.\n */\n\n/** Tests one mailbox may send in an hour: a few steps for a few people. */\nexport const OUTREACH_STEP_TESTS_PER_HOUR = 20\n\n/** What every test's subject starts with. */\nexport const OUTREACH_TEST_SUBJECT_PREFIX = '[Test] '\n\n/** The route's reach beyond the shared route deps: the mailbox's Gmail, the limit, the links. */\nexport interface OutreachStepTestDeps {\n /** Opens a mailbox's Gmail client from its sealed grant. */\n openMailbox(mailboxId: string): Promise<OpenedOutreachMailbox>\n consumeRateLimit(key: string, options: { limit: number; windowMs: number }): Promise<{ allowed: boolean }>\n /** The short tracking link for a stored link's id, or `null` when none can be made. */\n clickLinkUrl(linkId: string, trackingOrigin?: string | null): string | null\n /** The click-tracking origin for mail sent as `senderAddress` (AGL-3306), or `null` for the console's. */\n clickLinkOrigin?(input: { orgId: string; senderAddress: string }): Promise<string | null>\n}\n\n/** What the route answers: the response the console reads, and the fields the test sent empty. */\nexport interface OutreachStepTestAnswer extends OutreachStepTestResponse {\n unresolvedFields: string[]\n}\n\nconst RECONNECT_SENTENCE =\n \"This sequence's mailbox needs to be connected again before it can send. Reconnect it on the Mailboxes page.\"\n\n/** Why the mailbox cannot send a test now, in a sentence, or `null`. */\nfunction mailboxRefusal(mailbox: OutreachMailbox | null): string | null {\n if (!mailbox) return 'This sequence has no mailbox. Choose one and save before sending a test.'\n if (mailbox.status === 'paused') {\n return \"This sequence's mailbox is paused. Resume it on the Mailboxes page before sending a test.\"\n }\n if (mailbox.status === 'reconnect_required' || mailbox.status === 'disconnected') return RECONNECT_SENTENCE\n return null\n}\n\nexport function createOutreachStepTestRoute(\n deps: OutreachRouteDeps,\n testDeps: OutreachStepTestDeps,\n): PluginWebApiHandler {\n return async (request) => {\n if (request.method !== 'POST') return outreachMethodNotAllowed('POST')\n const body = await readOutreachJsonBody(request)\n // The address the member typed, when they typed one — read before any\n // record, so a typo is answered before a Gmail call.\n const named = typeof body['to'] === 'string' ? body['to'].trim() : ''\n const typed = named ? normalizeContactEmail(named) : null\n if (named && !typed) {\n return outreachRefusal(400, 'invalid-request', `\"${named}\" isn't a valid email address.`)\n }\n const loaded = await readOutreachStepRender(deps, request, body)\n if (loaded instanceof Response) return loaded\n const { caller, sequence, stepIndex, mailbox } = loaded\n\n const refusedBy = mailboxRefusal(mailbox)\n if (refusedBy || !mailbox) return outreachRefusal(409, 'mailbox-unavailable', refusedBy ?? RECONNECT_SENTENCE)\n if (mailbox.connectedByUid !== caller.uid && !caller.isOrgAdmin) {\n return outreachRefusal(\n 403,\n 'permission',\n \"Only the member who connected this sequence's mailbox, or an organization owner or admin, can send a test from it.\",\n )\n }\n const to = typed ?? caller.email\n if (!to) return outreachRefusal(400, 'invalid-request', 'Type an address to send the test to.')\n // The rules every sequence email keeps, asked of the stored steps: a\n // sequence saved under an older rule is refused rather than sent broken.\n const issues = validateOutreachSteps(sequence.steps)\n if (hasOutreachValidationErrors(issues)) {\n return outreachRefusal(400, 'invalid-sequence', \"Fix the sequence's steps before sending a test.\", { issues })\n }\n const limited = await testDeps.consumeRateLimit(`outreach-step-test:${mailbox.id}`, {\n limit: OUTREACH_STEP_TESTS_PER_HOUR,\n windowMs: 60 * 60 * 1000,\n })\n if (!limited.allowed) {\n return outreachRefusal(\n 429,\n 'rate-limited',\n `A mailbox can send ${OUTREACH_STEP_TESTS_PER_HOUR} tests an hour. Try again later.`,\n )\n }\n\n const firestore = deps.firestore()\n const nowMs = deps.now()\n const sender = { address: mailbox.sendAs || mailbox.email, name: mailbox.displayName || null }\n /*\n * The links, as a real send would rewrite them (AGL-3239, AGL-3297) and\n * marked as a test's: the member sees what a recipient sees, and their\n * own click counts for nothing.\n */\n let linkOrigin: string | null = null\n if ((sequence.settings.trackClicks || sequence.settings.countOpens) && testDeps.clickLinkOrigin) {\n linkOrigin = await testDeps\n .clickLinkOrigin({ orgId: caller.orgId, senderAddress: sender.address })\n .catch(() => null)\n }\n const shortLinks: Array<{ id: string; doc: OutreachStoredLink }> = []\n const rewriteLink = sequence.settings.trackClicks\n ? (link: { url: string; index: number }) => {\n const doc = outreachStoredLink(\n {\n orgId: caller.orgId,\n enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT,\n stepIndex,\n linkIndex: link.index,\n url: link.url,\n test: true,\n },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (!doc || !url) return null\n shortLinks.push({ id, doc: { ...doc, sequenceId: sequence.id } })\n return url\n }\n : null\n /*\n * The tracking image (AGL-3395), as a real send would carry it and\n * marked as a test's: the member sees both parts a recipient gets, and\n * their own open is recorded nowhere.\n */\n let pixel: { id: string; doc: OutreachStoredOpen; url: string } | null = null\n if (sequence.settings.countOpens) {\n const doc = outreachStoredOpen(\n { orgId: caller.orgId, enrollmentId: OUTREACH_TEST_LINK_ENROLLMENT, stepIndex, test: true },\n nowMs,\n )\n const id = newOutreachLinkId()\n const url = doc ? testDeps.clickLinkUrl(id, linkOrigin) : null\n if (doc && url) pixel = { id, doc: { ...doc, sequenceId: sequence.id }, url }\n }\n let composed = previewOutreachStep({ ...loaded.input, rewriteLink, openPixelUrl: pixel?.url ?? null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n if (shortLinks.length || pixel) {\n try {\n const batch = firestore.batch()\n for (const link of shortLinks) {\n batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(link.id), link.doc)\n }\n if (pixel) batch.create(firestore.collection(OUTREACH_COLLECTIONS.links).doc(pixel.id), pixel.doc)\n await batch.commit()\n } catch (error) {\n // A link that resolves nowhere is worse than one shown as written:\n // the test goes out with the step's own links, as plain text.\n console.error('[outreach] the test links could not be stored; the test carries the links as written', error)\n composed = previewOutreachStep({ ...loaded.input, rewriteLink: null })\n if (composed.error || !composed.email) {\n return outreachRefusal(409, 'invalid-sequence', composed.error?.message ?? \"This email can't be written yet.\")\n }\n }\n }\n\n const opened = await testDeps.openMailbox(mailbox.id)\n if (opened.ok === false) {\n if (opened.reason === 'not-configured') {\n return outreachRefusal(503, 'mailbox-unavailable', \"Google mailboxes aren't set up on this deployment.\")\n }\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: opened.reason,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', RECONNECT_SENTENCE)\n }\n /*\n * The preview's email, addressed to the member: `[Test]` in front, and a\n * `Re:` step sent as a new message — no thread id, no `In-Reply-To`, no\n * `References` — since the thread it answers exists only on screen. The\n * unsubscribe header is left off: it names an enrollment, and there is\n * none.\n */\n const email: ComposedOutreachEmail = {\n to,\n subject: `${OUTREACH_TEST_SUBJECT_PREFIX}${composed.email.subject}`,\n text: composed.email.text,\n ...(composed.email.html ? { html: composed.email.html } : {}),\n trackedLinks: composed.email.trackedLinks,\n }\n let subject: string\n try {\n subject = (await sendComposedOutreachEmail(opened.client, email, sender)).subject\n } catch (error) {\n if (error instanceof GmailTransportError && error.reconnectRequired) {\n await markOutreachMailboxReconnectRequired(firestore, {\n orgId: caller.orgId,\n mailboxId: mailbox.id,\n errorCode: error.code,\n nowMs,\n })\n return outreachRefusal(409, 'mailbox-unavailable', 'Google refused this mailbox’s access. Connect it again.')\n }\n if (error instanceof Rfc5322MessageError) {\n return outreachRefusal(400, 'invalid-request', `The test could not be written: ${error.message}`)\n }\n const answer = googleFailureAnswer(error, 'step test send')\n return outreachRefusal(answer.status, answer.reason, answer.error)\n }\n\n const testsToday = await countTest(firestore, caller.orgId, mailbox, nowMs)\n return outreachOk({\n ok: true,\n stepIndex,\n sentTo: to,\n subject,\n sentAtMs: nowMs,\n testsToday,\n unresolvedFields: composed.unresolvedFields,\n } satisfies OutreachStepTestAnswer)\n }\n}\n\n/**\n * One more test on the mailbox's local day, in `health.daily.<day>.tests`\n * beside the day's sends and never in them. Bookkeeping beside the act: the\n * email has left, so a failure is logged and answered as no count.\n */\nasync function countTest(\n firestore: FirebaseFirestore.Firestore,\n orgId: string,\n mailbox: OutreachMailbox,\n nowMs: number,\n): Promise<number> {\n const ref = mailboxRef(firestore, orgId, mailbox.id)\n try {\n return await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const stored = readStoredOutreachMailbox(mailbox.id, snapshot.exists ? snapshot.data() : undefined)\n if (!stored) return 0\n const day = outreachLocalDay(nowMs, stored.timezone)\n const counts = stored.health?.daily?.[day] ?? { sent: 0, bounces: 0, replies: 0 }\n const tests = Math.max(0, Number(counts.tests) || 0) + 1\n transaction.update(ref, { [`health.daily.${day}`]: { ...counts, tests }, updatedAtMs: nowMs })\n return tests\n })\n } catch (error) {\n console.warn('[outreach] a test send could not be counted on its mailbox', error)\n return 0\n }\n}\n"],"names":["normalizeContactEmail","hasOutreachValidationErrors","validateOutreachSteps","mailboxRef","outreachLocalDay","markOutreachMailboxReconnectRequired","OUTREACH_COLLECTIONS","previewOutreachStep","newOutreachLinkId","OUTREACH_TEST_LINK_ENROLLMENT","outreachStoredLink","outreachStoredOpen","readStoredOutreachMailbox","GmailTransportError","googleFailureAnswer","Rfc5322MessageError","sendComposedOutreachEmail","readOutreachStepRender","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachJsonBody","OUTREACH_STEP_TESTS_PER_HOUR","OUTREACH_TEST_SUBJECT_PREFIX","RECONNECT_SENTENCE","mailboxRefusal","mailbox","status","createOutreachStepTestRoute","deps","testDeps","request","method","body","named","trim","typed","loaded","Response","caller","sequence","stepIndex","refusedBy","connectedByUid","uid","isOrgAdmin","to","email","issues","steps","limited","consumeRateLimit","id","limit","windowMs","allowed","firestore","nowMs","now","sender","address","sendAs","name","displayName","linkOrigin","settings","trackClicks","countOpens","clickLinkOrigin","orgId","senderAddress","catch","shortLinks","rewriteLink","link","doc","enrollmentId","linkIndex","index","url","test","clickLinkUrl","push","sequenceId","pixel","composed","input","openPixelUrl","error","message","length","batch","create","collection","links","commit","console","opened","openMailbox","ok","reason","mailboxId","errorCode","subject","text","html","trackedLinks","client","reconnectRequired","code","answer","testsToday","countTest","sentTo","sentAtMs","unresolvedFields","ref","runTransaction","transaction","stored","snapshot","get","exists","data","undefined","day","timezone","counts","health","daily","sent","bounces","replies","tests","Math","max","Number","update","updatedAtMs","warn"],"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 { normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
17
+ */ import { normalizeContainerIds } from "@aglyn/aglyn/app-utils/container-membership";
18
18
  import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
19
19
  /**
20
20
  * WHAT A SEQUENCE PRODUCED, CREDITED TO ITS CAMPAIGNS (AGL-3254).
@@ -40,7 +40,7 @@ import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/
40
40
  * enrollment naming no campaign credits nothing, silently — a sequence
41
41
  * outside every campaign is the ordinary case, not an error.
42
42
  */ /** The campaigns an enrollment credits: what it carries, cleaned. */ export function outreachEnrollmentCampaignIds(enrollment) {
43
- return normalizeCampaignIds(enrollment.campaignIds);
43
+ return normalizeContainerIds(enrollment.campaignIds);
44
44
  }
45
45
  /** Whether the email about to be recorded is the enrollment's first. */ export function isOutreachFirstEmail(enrollment) {
46
46
  var _enrollment_stepRecords;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-credit.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 { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT A SEQUENCE PRODUCED, CREDITED TO ITS CAMPAIGNS (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and every person\n * enrolled carries the sequence's campaigns as they stood at enrollment.\n * From then on each outcome of the enrollment is credited to those\n * campaigns through the platform's campaign attribution — the runtime's\n * `campaignCredit` seam — and never by writing the marketing plugin's\n * documents or importing it:\n *\n * - `enrolled`, by the enroll route, when the enrollment is created;\n * - `sent`, here, when the enrollment's FIRST email leaves — which is also\n * when the person's own record gains the sequence's campaign as \"where\n * this came from\", because a first email is the touch;\n * - `replied`, here, when the reply stops the sequence;\n * - `converted`, here, when a lead in a sequence becomes a contact;\n * - `meetings`, by the booking door, from the touch the click route stamps.\n *\n * Every credit happens once per enrollment because each is tied to a state\n * change that happens once: there is one first email, a reply moves the\n * status to `replied` and nothing sends after, a lead converts once. An\n * enrollment naming no campaign credits nothing, silently — a sequence\n * outside every campaign is the ordinary case, not an error.\n */\n\n/** The campaigns an enrollment credits: what it carries, cleaned. */\nexport function outreachEnrollmentCampaignIds(\n enrollment: Pick<OutreachEnrollment, 'campaignIds'>,\n): string[] {\n return normalizeCampaignIds(enrollment.campaignIds)\n}\n\n/** Whether the email about to be recorded is the enrollment's first. */\nexport function isOutreachFirstEmail(enrollment: Pick<OutreachEnrollment, 'stepRecords'>): boolean {\n return !(enrollment.stepRecords ?? []).some((record) => record.kind === 'email')\n}\n\n/**\n * The first email of an enrollment left: `sent` on every campaign, and the\n * person's record credited to the first of them. One campaign on the\n * record, because the record's \"where this came from\" names one touch; the\n * sequence's first campaign is the one the rep listed first.\n */\nexport async function creditOutreachFirstSend(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const { enrollment, atMs } = input\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length || !isOutreachFirstEmail(enrollment)) return\n await deps.campaignCredit.credit({ hostId: enrollment.hostId, campaignIds, outcome: 'sent', atMs })\n const record =\n enrollment.target === 'lead' && enrollment.leadId\n ? { kind: 'lead' as const, refId: enrollment.leadId }\n : enrollment.contactId\n ? { kind: 'contact' as const, refId: enrollment.contactId }\n : null\n if (!record) return\n await deps.campaignCredit.attributeRecord({\n hostId: enrollment.hostId,\n ...record,\n campaignId: campaignIds[0],\n sequenceId: enrollment.sequenceId,\n enrollmentId: enrollment.id,\n atMs,\n })\n}\n\n/** A reply stopped the sequence: `replied` on every campaign. */\nexport async function creditOutreachReply(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const campaignIds = outreachEnrollmentCampaignIds(input.enrollment)\n if (!campaignIds.length) return\n await deps.campaignCredit.credit({\n hostId: input.enrollment.hostId,\n campaignIds,\n outcome: 'replied',\n atMs: input.atMs,\n })\n}\n\n/**\n * A person clicked a link in a sequence email: the sequence's campaign\n * becomes their last campaign touch on the site, so what they go on to do\n * there — book, submit a form — is credited to it by the platform's own\n * doors. The first campaign, for the record-side reason above.\n */\nexport async function recordOutreachSequenceTouch(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const { enrollment } = input\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length || !enrollment.email) return\n await deps.campaignCredit.recordTouch({\n hostId: enrollment.hostId,\n email: enrollment.email,\n campaignId: campaignIds[0],\n sequenceId: enrollment.sequenceId,\n enrollmentId: enrollment.id,\n atMs: input.atMs,\n })\n}\n\n/**\n * A lead in a sequence became a contact: `converted` on every campaign of\n * every enrollment made on the lead. Run from the lead-conversion seam,\n * which fires once per conversion. Answers how many enrollments credited.\n */\nexport async function creditOutreachLeadConversion(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'campaignCredit'>,\n input: { orgId: string; hostId: string; leadId: string },\n): Promise<number> {\n const rows = await outreachOrgCollection(deps.firestore(), input.orgId, 'enrollments')\n .where('leadId', '==', input.leadId)\n .get()\n const atMs = deps.now()\n let credited = 0\n for (const row of rows.docs) {\n const enrollment = readStoredOutreachEnrollment(row.id, row.data())\n if (!enrollment || enrollment.hostId !== input.hostId) continue\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length) continue\n await deps.campaignCredit.credit({\n hostId: input.hostId,\n orgId: input.orgId,\n campaignIds,\n outcome: 'converted',\n atMs,\n })\n credited += 1\n }\n return credited\n}\n"],"names":["normalizeCampaignIds","outreachOrgCollection","readStoredOutreachEnrollment","outreachEnrollmentCampaignIds","enrollment","campaignIds","isOutreachFirstEmail","stepRecords","some","record","kind","creditOutreachFirstSend","deps","input","atMs","length","campaignCredit","credit","hostId","outcome","target","leadId","refId","contactId","attributeRecord","campaignId","sequenceId","enrollmentId","id","creditOutreachReply","recordOutreachSequenceTouch","email","recordTouch","creditOutreachLeadConversion","rows","firestore","orgId","where","get","now","credited","row","docs","data"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-credit.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 type { OutreachEnrollment } from '../model/outreach.types'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT A SEQUENCE PRODUCED, CREDITED TO ITS CAMPAIGNS (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and every person\n * enrolled carries the sequence's campaigns as they stood at enrollment.\n * From then on each outcome of the enrollment is credited to those\n * campaigns through the platform's campaign attribution — the runtime's\n * `campaignCredit` seam — and never by writing the marketing plugin's\n * documents or importing it:\n *\n * - `enrolled`, by the enroll route, when the enrollment is created;\n * - `sent`, here, when the enrollment's FIRST email leaves — which is also\n * when the person's own record gains the sequence's campaign as \"where\n * this came from\", because a first email is the touch;\n * - `replied`, here, when the reply stops the sequence;\n * - `converted`, here, when a lead in a sequence becomes a contact;\n * - `meetings`, by the booking door, from the touch the click route stamps.\n *\n * Every credit happens once per enrollment because each is tied to a state\n * change that happens once: there is one first email, a reply moves the\n * status to `replied` and nothing sends after, a lead converts once. An\n * enrollment naming no campaign credits nothing, silently — a sequence\n * outside every campaign is the ordinary case, not an error.\n */\n\n/** The campaigns an enrollment credits: what it carries, cleaned. */\nexport function outreachEnrollmentCampaignIds(\n enrollment: Pick<OutreachEnrollment, 'campaignIds'>,\n): string[] {\n return normalizeContainerIds(enrollment.campaignIds)\n}\n\n/** Whether the email about to be recorded is the enrollment's first. */\nexport function isOutreachFirstEmail(enrollment: Pick<OutreachEnrollment, 'stepRecords'>): boolean {\n return !(enrollment.stepRecords ?? []).some((record) => record.kind === 'email')\n}\n\n/**\n * The first email of an enrollment left: `sent` on every campaign, and the\n * person's record credited to the first of them. One campaign on the\n * record, because the record's \"where this came from\" names one touch; the\n * sequence's first campaign is the one the rep listed first.\n */\nexport async function creditOutreachFirstSend(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const { enrollment, atMs } = input\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length || !isOutreachFirstEmail(enrollment)) return\n await deps.campaignCredit.credit({ hostId: enrollment.hostId, campaignIds, outcome: 'sent', atMs })\n const record =\n enrollment.target === 'lead' && enrollment.leadId\n ? { kind: 'lead' as const, refId: enrollment.leadId }\n : enrollment.contactId\n ? { kind: 'contact' as const, refId: enrollment.contactId }\n : null\n if (!record) return\n await deps.campaignCredit.attributeRecord({\n hostId: enrollment.hostId,\n ...record,\n campaignId: campaignIds[0],\n sequenceId: enrollment.sequenceId,\n enrollmentId: enrollment.id,\n atMs,\n })\n}\n\n/** A reply stopped the sequence: `replied` on every campaign. */\nexport async function creditOutreachReply(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const campaignIds = outreachEnrollmentCampaignIds(input.enrollment)\n if (!campaignIds.length) return\n await deps.campaignCredit.credit({\n hostId: input.enrollment.hostId,\n campaignIds,\n outcome: 'replied',\n atMs: input.atMs,\n })\n}\n\n/**\n * A person clicked a link in a sequence email: the sequence's campaign\n * becomes their last campaign touch on the site, so what they go on to do\n * there — book, submit a form — is credited to it by the platform's own\n * doors. The first campaign, for the record-side reason above.\n */\nexport async function recordOutreachSequenceTouch(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const { enrollment } = input\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length || !enrollment.email) return\n await deps.campaignCredit.recordTouch({\n hostId: enrollment.hostId,\n email: enrollment.email,\n campaignId: campaignIds[0],\n sequenceId: enrollment.sequenceId,\n enrollmentId: enrollment.id,\n atMs: input.atMs,\n })\n}\n\n/**\n * A lead in a sequence became a contact: `converted` on every campaign of\n * every enrollment made on the lead. Run from the lead-conversion seam,\n * which fires once per conversion. Answers how many enrollments credited.\n */\nexport async function creditOutreachLeadConversion(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'campaignCredit'>,\n input: { orgId: string; hostId: string; leadId: string },\n): Promise<number> {\n const rows = await outreachOrgCollection(deps.firestore(), input.orgId, 'enrollments')\n .where('leadId', '==', input.leadId)\n .get()\n const atMs = deps.now()\n let credited = 0\n for (const row of rows.docs) {\n const enrollment = readStoredOutreachEnrollment(row.id, row.data())\n if (!enrollment || enrollment.hostId !== input.hostId) continue\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length) continue\n await deps.campaignCredit.credit({\n hostId: input.hostId,\n orgId: input.orgId,\n campaignIds,\n outcome: 'converted',\n atMs,\n })\n credited += 1\n }\n return credited\n}\n"],"names":["normalizeContainerIds","outreachOrgCollection","readStoredOutreachEnrollment","outreachEnrollmentCampaignIds","enrollment","campaignIds","isOutreachFirstEmail","stepRecords","some","record","kind","creditOutreachFirstSend","deps","input","atMs","length","campaignCredit","credit","hostId","outcome","target","leadId","refId","contactId","attributeRecord","campaignId","sequenceId","enrollmentId","id","creditOutreachReply","recordOutreachSequenceTouch","email","recordTouch","creditOutreachLeadConversion","rows","firestore","orgId","where","get","now","credited","row","docs","data"],"mappings":"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"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
16
+ */ import { containerMembershipField } from "@aglyn/aglyn/app-utils/container-membership";
17
17
  import { FieldValue } from "firebase-admin/firestore";
18
18
  import { outreachOrgCollection } from "../storage/outreach-records.js";
19
19
  /**
@@ -35,7 +35,8 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
35
35
  * the campaign's credits: what a sequence produced for a campaign that is
36
36
  * gone is in a report document nobody reads any more, which is the
37
37
  * conversions rollup's own fate.
38
- */ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
38
+ */ /** The field a sequence or an enrollment holds its campaigns in. */ const CAMPAIGN_MEMBERSHIP_FIELD = containerMembershipField('campaign');
39
+ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
39
40
  /** How many pages one request walks per collection. */ const DETACH_PASSES = 25;
40
41
  async function detachFrom(collection, campaignId) {
41
42
  const firestore = collection.firestore;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-detach.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 { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).\n *\n * A sequence and its enrollments name the campaigns they are in from their\n * own documents, under the organization, in collections the core does not\n * name — so the marketing plugin's deletion cannot reach them and asks\n * every plugin's membership detacher instead. This one clears the campaign\n * id out of every sequence and every enrollment of the org still carrying\n * it, with the same walk the owner makes over its forms: `array-contains`\n * on the automatic single-field index, `arrayRemove` of the one id, in\n * pages under Firestore's batch limit, bounded per request and reporting\n * `remaining` so the deletion holds the container for a second run.\n *\n * Only the campaign membership field is Outreach's to clear: a request\n * about any other container's field answers nothing detached, because the\n * sequences carry no such field. The records themselves stay, and so do\n * the campaign's credits: what a sequence produced for a campaign that is\n * gone is in a report document nobody reads any more, which is the\n * conversions rollup's own fate.\n */\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n campaignId: string,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId) })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the Firestore it is handed. */\nexport function createOutreachCampaignDetacher(deps: {\n firestore(): FirebaseFirestore.Firestore\n}): PluginMembershipDetacher {\n return async ({ orgId, field, id }) => {\n // A site with no organization holds no sequences, and a container held\n // in any other field is not one a sequence joins: nothing to detach.\n if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id)\n const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id)\n return {\n detached: sequences.detached + enrollments.detached,\n remaining: sequences.remaining || enrollments.remaining,\n }\n }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","FieldValue","outreachOrgCollection","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","campaignId","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","size","createOutreachCampaignDetacher","deps","orgId","field","id","sequences","enrollments"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAKtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAEnE;;;;;;;;;;;;;;;;;;;CAmBC,GAED,wEAAwE,GACxE,MAAMC,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAAgB;AAEtB,eAAeC,WACbC,UAAiD,EACjDC,UAAkB;IAElB,MAAMC,YAAYF,WAAWE,SAAS;IACtC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,WAAWM,KAAK,CAACZ,2BAA2B,kBAAkBO,YAAYM,KAAK,CAACV,cAAcW,GAAG;QACpH,IAAIH,KAAKI,KAAK,EAAE,OAAO;YAAEN;YAAUO,WAAW;QAAM;QACpD,MAAMC,QAAQT,UAAUS,KAAK;QAC7B,KAAK,MAAMC,UAAUP,KAAKQ,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACF,OAAOG,GAAG,EAAE;gBAAE,CAACrB,0BAA0B,EAAEC,WAAWqB,WAAW,CAACf;YAAY;QAC7F;QACA,MAAMU,MAAMM,MAAM;QAClBd,YAAYE,KAAKa,IAAI;QACrB,IAAIb,KAAKa,IAAI,GAAGrB,cAAc,OAAO;YAAEM;YAAUO,WAAW;QAAM;IACpE;IACA,OAAO;QAAEP;QAAUO,WAAW;IAAK;AACrC;AAEA,iDAAiD,GACjD,OAAO,SAASS,+BAA+BC,IAE9C;IACC,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,EAAE,EAAE;QAChC,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,CAACF,SAASC,UAAU5B,2BAA2B,OAAO;YAAES,UAAU;YAAGO,WAAW;QAAM;QAC1F,MAAMR,YAAYkB,KAAKlB,SAAS;QAChC,MAAMsB,YAAY,MAAMzB,WAAWH,sBAAsBM,WAAWmB,OAAO,cAAcE;QACzF,MAAME,cAAc,MAAM1B,WAAWH,sBAAsBM,WAAWmB,OAAO,gBAAgBE;QAC7F,OAAO;YACLpB,UAAUqB,UAAUrB,QAAQ,GAAGsB,YAAYtB,QAAQ;YACnDO,WAAWc,UAAUd,SAAS,IAAIe,YAAYf,SAAS;QACzD;IACF;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-detach.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 { containerMembershipField } from '@aglyn/aglyn/app-utils/container-membership'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).\n *\n * A sequence and its enrollments name the campaigns they are in from their\n * own documents, under the organization, in collections the core does not\n * name — so the marketing plugin's deletion cannot reach them and asks\n * every plugin's membership detacher instead. This one clears the campaign\n * id out of every sequence and every enrollment of the org still carrying\n * it, with the same walk the owner makes over its forms: `array-contains`\n * on the automatic single-field index, `arrayRemove` of the one id, in\n * pages under Firestore's batch limit, bounded per request and reporting\n * `remaining` so the deletion holds the container for a second run.\n *\n * Only the campaign membership field is Outreach's to clear: a request\n * about any other container's field answers nothing detached, because the\n * sequences carry no such field. The records themselves stay, and so do\n * the campaign's credits: what a sequence produced for a campaign that is\n * gone is in a report document nobody reads any more, which is the\n * conversions rollup's own fate.\n */\n\n/** The field a sequence or an enrollment holds its campaigns in. */\nconst CAMPAIGN_MEMBERSHIP_FIELD = containerMembershipField('campaign')\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n campaignId: string,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId) })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the Firestore it is handed. */\nexport function createOutreachCampaignDetacher(deps: {\n firestore(): FirebaseFirestore.Firestore\n}): PluginMembershipDetacher {\n return async ({ orgId, field, id }) => {\n // A site with no organization holds no sequences, and a container held\n // in any other field is not one a sequence joins: nothing to detach.\n if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id)\n const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id)\n return {\n detached: sequences.detached + enrollments.detached,\n remaining: sequences.remaining || enrollments.remaining,\n }\n }\n}\n"],"names":["containerMembershipField","FieldValue","outreachOrgCollection","CAMPAIGN_MEMBERSHIP_FIELD","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","campaignId","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","size","createOutreachCampaignDetacher","deps","orgId","field","id","sequences","enrollments"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,wBAAwB,QAAQ,8CAA6C;AAKtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAEnE;;;;;;;;;;;;;;;;;;;CAmBC,GAED,kEAAkE,GAClE,MAAMC,4BAA4BH,yBAAyB;AAE3D,wEAAwE,GACxE,MAAMI,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAAgB;AAEtB,eAAeC,WACbC,UAAiD,EACjDC,UAAkB;IAElB,MAAMC,YAAYF,WAAWE,SAAS;IACtC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,WAAWM,KAAK,CAACV,2BAA2B,kBAAkBK,YAAYM,KAAK,CAACV,cAAcW,GAAG;QACpH,IAAIH,KAAKI,KAAK,EAAE,OAAO;YAAEN;YAAUO,WAAW;QAAM;QACpD,MAAMC,QAAQT,UAAUS,KAAK;QAC7B,KAAK,MAAMC,UAAUP,KAAKQ,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACF,OAAOG,GAAG,EAAE;gBAAE,CAACnB,0BAA0B,EAAEF,WAAWsB,WAAW,CAACf;YAAY;QAC7F;QACA,MAAMU,MAAMM,MAAM;QAClBd,YAAYE,KAAKa,IAAI;QACrB,IAAIb,KAAKa,IAAI,GAAGrB,cAAc,OAAO;YAAEM;YAAUO,WAAW;QAAM;IACpE;IACA,OAAO;QAAEP;QAAUO,WAAW;IAAK;AACrC;AAEA,iDAAiD,GACjD,OAAO,SAASS,+BAA+BC,IAE9C;IACC,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,EAAE,EAAE;QAChC,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,CAACF,SAASC,UAAU1B,2BAA2B,OAAO;YAAEO,UAAU;YAAGO,WAAW;QAAM;QAC1F,MAAMR,YAAYkB,KAAKlB,SAAS;QAChC,MAAMsB,YAAY,MAAMzB,WAAWJ,sBAAsBO,WAAWmB,OAAO,cAAcE;QACzF,MAAME,cAAc,MAAM1B,WAAWJ,sBAAsBO,WAAWmB,OAAO,gBAAgBE;QAC7F,OAAO;YACLpB,UAAUqB,UAAUrB,QAAQ,GAAGsB,YAAYtB,QAAQ;YACnDO,WAAWc,UAAUd,SAAS,IAAIe,YAAYf,SAAS;QACzD;IACF;AACF"}
@@ -16,7 +16,12 @@
16
16
  */
17
17
  import type { PluginLeadConversionReport, PluginLeadConversionRequest } from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion';
18
18
  type Firestore = FirebaseFirestore.Firestore;
19
- /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */
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. */
20
25
  export declare function markOutreachLeadWorking(firestore: Firestore, input: {
21
26
  orgId: string;
22
27
  leadId: string;
@@ -16,11 +16,16 @@
16
16
  */ /**
17
17
  * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).
18
18
  *
19
- * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's
19
+ * Three touches, all on `orgs/{orgId}/leads/{leadId}` — the record system's
20
20
  * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead
21
21
  * statuses), never through an import of the CRM plugin:
22
22
  *
23
- * - a REPLY moves a lead nobody has touched from New to Working. Somebody
23
+ * - a SENT STEP moves a lead nobody has touched from New to Nurturing.
24
+ * Automated email is reaching it, so it is not untouched — the digest's
25
+ * unworked list would otherwise name every lead a sequence is working —
26
+ * but no person has engaged yet. Only a send moves it, never the
27
+ * enrollment: a step that never goes out has reached nobody.
28
+ * - a REPLY moves a lead that is New or Nurturing to Working. Somebody
24
29
  * wrote back; the lead is being worked, and a queue that still showed it
25
30
  * as New would send a rep to open a conversation already open. A lead
26
31
  * already Working, closed, or converted is left as it is — a reply does
@@ -38,7 +43,10 @@
38
43
  import { FieldValue } from "firebase-admin/firestore";
39
44
  import { outreachOrgCollection } from "../storage/outreach-records.js";
40
45
  /** Firestore refuses a batch of more than 500 writes. */ const BATCH_LIMIT = 450;
41
- /** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */ export async function markOutreachLeadWorking(firestore, input) {
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) {
42
50
  try {
43
51
  var _snapshot_data;
44
52
  // A lead is one org row (AGL-3275), so the site no longer names it.
@@ -46,19 +54,30 @@ import { outreachOrgCollection } from "../storage/outreach-records.js";
46
54
  const snapshot = await ref.get();
47
55
  if (!snapshot.exists) return false;
48
56
  const lead = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
49
- if (lead['convertedContactId'] || crmLeadStatus(lead) !== 'new') return false;
57
+ if (lead['convertedContactId'] || !from.includes(crmLeadStatus(lead))) return false;
50
58
  await ref.set({
51
- status: 'working',
59
+ status: to,
52
60
  updatedAt: FieldValue.serverTimestamp()
53
61
  }, {
54
62
  merge: true
55
63
  });
56
64
  return true;
57
65
  } catch (error) {
58
- console.error('[outreach] the lead could not be marked working', input.orgId, input.leadId, error);
66
+ console.error(`[outreach] the lead could not be marked ${to}`, input.orgId, input.leadId, error);
59
67
  return false;
60
68
  }
61
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
+ }
62
81
  /** Every enrollment made on the lead now names the contact it became. */ export async function followOutreachLeadToContact(firestore, input) {
63
82
  const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments');
64
83
  const rows = await enrollments.where('leadId', '==', input.leadId).get();
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/lead-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).\n *\n * Two touches, both on `hosts/{hostId}/leads/{leadId}` — the record system's\n * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead\n * statuses), never through an import of the CRM plugin:\n *\n * - a REPLY moves a lead nobody has touched from New to Working. Somebody\n * wrote back; the lead is being worked, and a queue that still showed it\n * as New would send a rep to open a conversation already open. A lead\n * already Working, closed, or converted is left as it is — a reply does\n * not reopen a verdict or move a contact's record.\n * - a CONVERSION re-points every enrollment made on the lead at the contact\n * it became: the record system tells every plugin through its\n * lead-conversion seam, and this is Sequences' share of it. The\n * enrollment keeps its id, its thread and its place in the steps, and\n * keeps naming the lead beside the contact, so both pages list it.\n *\n * Neither throws: a status that could not be moved is one a rep moves by\n * hand, and an enrollment that could not follow its lead is one the next\n * send stops as `contact_missing` and a rep re-enrolls.\n */\n\nimport { crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/** A lead nobody has touched, once somebody replied: Working. `true` when it moved. */\nexport async function markOutreachLeadWorking(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n try {\n // A lead is one org row (AGL-3275), so the site no longer names it.\n const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId'] || crmLeadStatus(lead as never) !== 'new') return false\n await ref.set({ status: 'working', updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n return true\n } catch (error) {\n console.error('[outreach] the lead could not be marked working', input.orgId, input.leadId, error)\n return false\n }\n}\n\n/** Every enrollment made on the lead now names the contact it became. */\nexport async function followOutreachLeadToContact(\n firestore: Firestore,\n input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>,\n): Promise<PluginLeadConversionReport> {\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n const rows = await enrollments.where('leadId', '==', input.leadId).get()\n const refs = rows.docs\n .filter((row) => row.get('target') !== 'contact' || String(row.get('contactId') ?? '') !== input.contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { target: 'contact', contactId: input.contactId, updatedAtMs: Date.now() })\n }\n await batch.commit()\n }\n return { enrollments: refs.length }\n}\n"],"names":["crmLeadStatus","FieldValue","outreachOrgCollection","BATCH_LIMIT","markOutreachLeadWorking","firestore","input","snapshot","ref","collection","doc","orgId","leadId","get","exists","lead","data","set","status","updatedAt","serverTimestamp","merge","error","console","followOutreachLeadToContact","enrollments","rows","where","refs","docs","filter","row","String","contactId","map","start","length","batch","slice","update","target","updatedAtMs","Date","now","commit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;CAqBC,GAED,SAASA,aAAa,QAAQ,6BAA4B;AAK1D,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB,qFAAqF,GACrF,OAAO,eAAeC,wBACpBC,SAAoB,EACpBC,KAAwC;IAExC,IAAI;YAKWC;QAJb,oEAAoE;QACpE,MAAMC,MAAMH,UAAUI,UAAU,CAAC,QAAQC,GAAG,CAACJ,MAAMK,KAAK,EAAEF,UAAU,CAAC,SAASC,GAAG,CAACJ,MAAMM,MAAM;QAC9F,MAAML,WAAW,MAAMC,IAAIK,GAAG;QAC9B,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAOR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QACjC,IAAIQ,IAAI,CAAC,qBAAqB,IAAIf,cAAce,UAAmB,OAAO,OAAO;QACjF,MAAMP,IAAIS,GAAG,CAAC;YAAEC,QAAQ;YAAWC,WAAWlB,WAAWmB,eAAe;QAAG,GAAG;YAAEC,OAAO;QAAK;QAC5F,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,mDAAmDhB,MAAMK,KAAK,EAAEL,MAAMM,MAAM,EAAEU;QAC5F,OAAO;IACT;AACF;AAEA,uEAAuE,GACvE,OAAO,eAAeE,4BACpBnB,SAAoB,EACpBC,KAA0E;IAE1E,MAAMmB,cAAcvB,sBAAsBG,WAAWC,MAAMK,KAAK,EAAE;IAClE,MAAMe,OAAO,MAAMD,YAAYE,KAAK,CAAC,UAAU,MAAMrB,MAAMM,MAAM,EAAEC,GAAG;IACtE,MAAMe,OAAOF,KAAKG,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAkDA;eAA1CA,IAAIlB,GAAG,CAAC,cAAc,aAAamB,QAAOD,WAAAA,IAAIlB,GAAG,CAAC,wBAARkB,WAAwB,QAAQzB,MAAM2B,SAAS;OACzGC,GAAG,CAAC,CAACH,MAAQA,IAAIvB,GAAG;IACvB,IAAK,IAAI2B,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAAShC,YAAa;QAC7D,MAAMkC,QAAQhC,UAAUgC,KAAK;QAC7B,KAAK,MAAM7B,OAAOoB,KAAKU,KAAK,CAACH,OAAOA,QAAQhC,aAAc;YACxDkC,MAAME,MAAM,CAAC/B,KAAK;gBAAEgC,QAAQ;gBAAWP,WAAW3B,MAAM2B,SAAS;gBAAEQ,aAAaC,KAAKC,GAAG;YAAG;QAC7F;QACA,MAAMN,MAAMO,MAAM;IACpB;IACA,OAAO;QAAEnB,aAAaG,KAAKQ,MAAM;IAAC;AACpC"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/lead-records.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/**\n * WHAT A SEQUENCE DOES TO A LEAD'S OWN RECORD (AGL-3234).\n *\n * Three touches, all on `orgs/{orgId}/leads/{leadId}` — the record system's\n * document, written in the record system's vocabulary (`@aglyn/aglyn`'s lead\n * statuses), never through an import of the CRM plugin:\n *\n * - a SENT STEP moves a lead nobody has touched from New to Nurturing.\n * Automated email is reaching it, so it is not untouched — the digest's\n * unworked list would otherwise name every lead a sequence is working —\n * but no person has engaged yet. Only a send moves it, never the\n * enrollment: a step that never goes out has reached nobody.\n * - a REPLY moves a lead that is New or Nurturing to Working. Somebody\n * wrote back; the lead is being worked, and a queue that still showed it\n * as New would send a rep to open a conversation already open. A lead\n * already Working, closed, or converted is left as it is — a reply does\n * not reopen a verdict or move a contact's record.\n * - a CONVERSION re-points every enrollment made on the lead at the contact\n * it became: the record system tells every plugin through its\n * lead-conversion seam, and this is Sequences' share of it. The\n * enrollment keeps its id, its thread and its place in the steps, and\n * keeps naming the lead beside the contact, so both pages list it.\n *\n * Neither throws: a status that could not be moved is one a rep moves by\n * hand, and an enrollment that could not follow its lead is one the next\n * send stops as `contact_missing` and a rep re-enrolls.\n */\n\nimport { type CrmLeadStatus, crmLeadStatus } from '@aglyn/aglyn/app-utils/crm'\nimport type {\n PluginLeadConversionReport,\n PluginLeadConversionRequest,\n} from '@aglyn/aglyn/plugin-manager/plugin-lead-conversion'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\ntype Firestore = FirebaseFirestore.Firestore\n\n/** Firestore refuses a batch of more than 500 writes. */\nconst BATCH_LIMIT = 450\n\n/**\n * Move a lead forward to `to` when it stands in one of `from`; `true` when\n * it moved. A converted lead is a contact's history and never moves.\n */\nasync function advanceOutreachLead(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n from: readonly CrmLeadStatus[],\n to: CrmLeadStatus,\n): Promise<boolean> {\n try {\n // A lead is one org row (AGL-3275), so the site no longer names it.\n const ref = firestore.collection('orgs').doc(input.orgId).collection('leads').doc(input.leadId)\n const snapshot = await ref.get()\n if (!snapshot.exists) return false\n const lead = snapshot.data() ?? {}\n if (lead['convertedContactId'] || !from.includes(crmLeadStatus(lead as never))) return false\n await ref.set({ status: to, updatedAt: FieldValue.serverTimestamp() }, { merge: true })\n return true\n } catch (error) {\n console.error(`[outreach] the lead could not be marked ${to}`, input.orgId, input.leadId, error)\n return false\n }\n}\n\n/** A lead nobody has touched, once a step was sent to it: Nurturing. `true` when it moved. */\nexport function markOutreachLeadNurturing(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n return advanceOutreachLead(firestore, input, ['new'], 'nurturing')\n}\n\n/** A lead no person has engaged, once somebody replied: Working. `true` when it moved. */\nexport function markOutreachLeadWorking(\n firestore: Firestore,\n input: { orgId: string; leadId: string },\n): Promise<boolean> {\n return advanceOutreachLead(firestore, input, ['new', 'nurturing'], 'working')\n}\n\n/** Every enrollment made on the lead now names the contact it became. */\nexport async function followOutreachLeadToContact(\n firestore: Firestore,\n input: Pick<PluginLeadConversionRequest, 'orgId' | 'leadId' | 'contactId'>,\n): Promise<PluginLeadConversionReport> {\n const enrollments = outreachOrgCollection(firestore, input.orgId, 'enrollments')\n const rows = await enrollments.where('leadId', '==', input.leadId).get()\n const refs = rows.docs\n .filter((row) => row.get('target') !== 'contact' || String(row.get('contactId') ?? '') !== input.contactId)\n .map((row) => row.ref)\n for (let start = 0; start < refs.length; start += BATCH_LIMIT) {\n const batch = firestore.batch()\n for (const ref of refs.slice(start, start + BATCH_LIMIT)) {\n batch.update(ref, { target: 'contact', contactId: input.contactId, updatedAtMs: Date.now() })\n }\n await batch.commit()\n }\n return { enrollments: refs.length }\n}\n"],"names":["crmLeadStatus","FieldValue","outreachOrgCollection","BATCH_LIMIT","advanceOutreachLead","firestore","input","from","to","snapshot","ref","collection","doc","orgId","leadId","get","exists","lead","data","includes","set","status","updatedAt","serverTimestamp","merge","error","console","markOutreachLeadNurturing","markOutreachLeadWorking","followOutreachLeadToContact","enrollments","rows","where","refs","docs","filter","row","String","contactId","map","start","length","batch","slice","update","target","updatedAtMs","Date","now","commit"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;;;;;;;CA0BC,GAED,SAA6BA,aAAa,QAAQ,6BAA4B;AAK9E,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAInE,uDAAuD,GACvD,MAAMC,cAAc;AAEpB;;;CAGC,GACD,eAAeC,oBACbC,SAAoB,EACpBC,KAAwC,EACxCC,IAA8B,EAC9BC,EAAiB;IAEjB,IAAI;YAKWC;QAJb,oEAAoE;QACpE,MAAMC,MAAML,UAAUM,UAAU,CAAC,QAAQC,GAAG,CAACN,MAAMO,KAAK,EAAEF,UAAU,CAAC,SAASC,GAAG,CAACN,MAAMQ,MAAM;QAC9F,MAAML,WAAW,MAAMC,IAAIK,GAAG;QAC9B,IAAI,CAACN,SAASO,MAAM,EAAE,OAAO;QAC7B,MAAMC,QAAOR,iBAAAA,SAASS,IAAI,cAAbT,iBAAmB,CAAC;QACjC,IAAIQ,IAAI,CAAC,qBAAqB,IAAI,CAACV,KAAKY,QAAQ,CAACnB,cAAciB,QAAiB,OAAO;QACvF,MAAMP,IAAIU,GAAG,CAAC;YAAEC,QAAQb;YAAIc,WAAWrB,WAAWsB,eAAe;QAAG,GAAG;YAAEC,OAAO;QAAK;QACrF,OAAO;IACT,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAAC,CAAC,wCAAwC,EAAEjB,IAAI,EAAEF,MAAMO,KAAK,EAAEP,MAAMQ,MAAM,EAAEW;QAC1F,OAAO;IACT;AACF;AAEA,4FAA4F,GAC5F,OAAO,SAASE,0BACdtB,SAAoB,EACpBC,KAAwC;IAExC,OAAOF,oBAAoBC,WAAWC,OAAO;QAAC;KAAM,EAAE;AACxD;AAEA,wFAAwF,GACxF,OAAO,SAASsB,wBACdvB,SAAoB,EACpBC,KAAwC;IAExC,OAAOF,oBAAoBC,WAAWC,OAAO;QAAC;QAAO;KAAY,EAAE;AACrE;AAEA,uEAAuE,GACvE,OAAO,eAAeuB,4BACpBxB,SAAoB,EACpBC,KAA0E;IAE1E,MAAMwB,cAAc5B,sBAAsBG,WAAWC,MAAMO,KAAK,EAAE;IAClE,MAAMkB,OAAO,MAAMD,YAAYE,KAAK,CAAC,UAAU,MAAM1B,MAAMQ,MAAM,EAAEC,GAAG;IACtE,MAAMkB,OAAOF,KAAKG,IAAI,CACnBC,MAAM,CAAC,CAACC;YAAkDA;eAA1CA,IAAIrB,GAAG,CAAC,cAAc,aAAasB,QAAOD,WAAAA,IAAIrB,GAAG,CAAC,wBAARqB,WAAwB,QAAQ9B,MAAMgC,SAAS;OACzGC,GAAG,CAAC,CAACH,MAAQA,IAAI1B,GAAG;IACvB,IAAK,IAAI8B,QAAQ,GAAGA,QAAQP,KAAKQ,MAAM,EAAED,SAASrC,YAAa;QAC7D,MAAMuC,QAAQrC,UAAUqC,KAAK;QAC7B,KAAK,MAAMhC,OAAOuB,KAAKU,KAAK,CAACH,OAAOA,QAAQrC,aAAc;YACxDuC,MAAME,MAAM,CAAClC,KAAK;gBAAEmC,QAAQ;gBAAWP,WAAWhC,MAAMgC,SAAS;gBAAEQ,aAAaC,KAAKC,GAAG;YAAG;QAC7F;QACA,MAAMN,MAAMO,MAAM;IACpB;IACA,OAAO;QAAEnB,aAAaG,KAAKQ,MAAM;IAAC;AACpC"}
@@ -13,7 +13,7 @@
13
13
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
- */ import { EMAIL_TOPIC_SALES } from "@aglyn/aglyn/app-utils/email-topics";
16
+ */ import { SALES_TOPIC_ID } from "../model/sales-topic.js";
17
17
  import { platformMailDnsResolver } from "@aglyn/tenant-data-admin/server/email-deliverability";
18
18
  import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
19
19
  import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
@@ -21,7 +21,7 @@ import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-r
21
21
  import { attributeCampaignConversion, creditCampaignSequenceOutcome } from "@aglyn/tenant-data-admin/server/campaign-conversion-attribution";
22
22
  import { recordEmailCampaignTouch } from "@aglyn/tenant-data-admin/server/email-delivery-log";
23
23
  import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression";
24
- import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/email-topic-confirmation";
24
+ import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/topic-subscriptions";
25
25
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
26
26
  import { getLockdownVerdict } from "@aglyn/tenant-data-admin/server/lockdown";
27
27
  import { sendOrgMemberNotice } from "@aglyn/tenant-data-admin/server/org-member-notice";
@@ -131,7 +131,7 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
131
131
  },
132
132
  logOrgActivity: (orgId, actor, action, target)=>logOrgActivity(orgId, actor, action, target),
133
133
  async optOutOfSalesTopic ({ hostId, email }) {
134
- await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, {
134
+ await recordTopicOptOut(hostId, email, SALES_TOPIC_ID, {
135
135
  firestore: firestore()
136
136
  });
137
137
  },