@aglyn/plugins-outreach 1.0.0-beta.200 → 1.0.0-beta.201

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 (90) hide show
  1. package/package.json +14 -14
  2. package/src/lib/components/enrollment-actions.d.ts +33 -0
  3. package/src/lib/components/enrollment-actions.js +174 -0
  4. package/src/lib/components/enrollment-actions.js.map +1 -0
  5. package/src/lib/components/enrollment-detail.d.ts +21 -0
  6. package/src/lib/components/enrollment-detail.js +870 -0
  7. package/src/lib/components/enrollment-detail.js.map +1 -0
  8. package/src/lib/components/enrollment-filter-params.d.ts +58 -0
  9. package/src/lib/components/enrollment-filter-params.js +98 -0
  10. package/src/lib/components/enrollment-filter-params.js.map +1 -0
  11. package/src/lib/components/enrollments-table.d.ts +21 -0
  12. package/src/lib/components/enrollments-table.js +249 -210
  13. package/src/lib/components/enrollments-table.js.map +1 -1
  14. package/src/lib/components/outreach-console-page.js +10 -0
  15. package/src/lib/components/outreach-console-page.js.map +1 -1
  16. package/src/lib/components/sequence-detail.d.ts +2 -0
  17. package/src/lib/components/sequence-detail.js +70 -8
  18. package/src/lib/components/sequence-detail.js.map +1 -1
  19. package/src/lib/components/sequence-report-card.d.ts +17 -0
  20. package/src/lib/components/sequence-report-card.js +63 -15
  21. package/src/lib/components/sequence-report-card.js.map +1 -1
  22. package/src/lib/components/sequences-section.d.ts +4 -1
  23. package/src/lib/components/sequences-section.js +12 -1
  24. package/src/lib/components/sequences-section.js.map +1 -1
  25. package/src/lib/components/use-outreach-api.d.ts +3 -1
  26. package/src/lib/components/use-outreach-api.js +6 -0
  27. package/src/lib/components/use-outreach-api.js.map +1 -1
  28. package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
  29. package/src/lib/components/use-outreach-enrollment.js +133 -0
  30. package/src/lib/components/use-outreach-enrollment.js.map +1 -0
  31. package/src/lib/constants/api-routes.d.ts +2 -0
  32. package/src/lib/constants/api-routes.js +1 -0
  33. package/src/lib/constants/api-routes.js.map +1 -1
  34. package/src/lib/engine/gates.d.ts +4 -3
  35. package/src/lib/engine/gates.js.map +1 -1
  36. package/src/lib/engine/gateway-block.d.ts +4 -8
  37. package/src/lib/engine/gateway-block.js +6 -26
  38. package/src/lib/engine/gateway-block.js.map +1 -1
  39. package/src/lib/engine/mail-gateway.d.ts +18 -73
  40. package/src/lib/engine/mail-gateway.js +32 -206
  41. package/src/lib/engine/mail-gateway.js.map +1 -1
  42. package/src/lib/model/enrollment-engagement.d.ts +26 -2
  43. package/src/lib/model/enrollment-engagement.js +40 -3
  44. package/src/lib/model/enrollment-engagement.js.map +1 -1
  45. package/src/lib/model/enrollment-history.d.ts +40 -0
  46. package/src/lib/model/enrollment-history.js +95 -0
  47. package/src/lib/model/enrollment-history.js.map +1 -0
  48. package/src/lib/model/enrollment-list-query.d.ts +6 -0
  49. package/src/lib/model/enrollment-list-query.js +35 -1
  50. package/src/lib/model/enrollment-list-query.js.map +1 -1
  51. package/src/lib/model/enrollment-timeline.d.ts +78 -0
  52. package/src/lib/model/enrollment-timeline.js +430 -0
  53. package/src/lib/model/enrollment-timeline.js.map +1 -0
  54. package/src/lib/model/outreach-api.d.ts +24 -0
  55. package/src/lib/model/outreach-api.js.map +1 -1
  56. package/src/lib/model/outreach.types.d.ts +78 -29
  57. package/src/lib/model/outreach.types.js +33 -8
  58. package/src/lib/model/outreach.types.js.map +1 -1
  59. package/src/lib/routes/enroll-routes.js +26 -3
  60. package/src/lib/routes/enroll-routes.js.map +1 -1
  61. package/src/lib/routes/enrollment-routes.d.ts +10 -0
  62. package/src/lib/routes/enrollment-routes.js +82 -1
  63. package/src/lib/routes/enrollment-routes.js.map +1 -1
  64. package/src/lib/routes/register-routes.js +14 -4
  65. package/src/lib/routes/register-routes.js.map +1 -1
  66. package/src/lib/routes/route-deps.d.ts +3 -1
  67. package/src/lib/routes/route-deps.js.map +1 -1
  68. package/src/lib/runtime/click-events.d.ts +10 -5
  69. package/src/lib/runtime/click-events.js +45 -6
  70. package/src/lib/runtime/click-events.js.map +1 -1
  71. package/src/lib/runtime/gateway-ledger.d.ts +6 -0
  72. package/src/lib/runtime/gateway-ledger.js +9 -3
  73. package/src/lib/runtime/gateway-ledger.js.map +1 -1
  74. package/src/lib/runtime/host-suspension.d.ts +30 -0
  75. package/src/lib/runtime/host-suspension.js +35 -0
  76. package/src/lib/runtime/host-suspension.js.map +1 -0
  77. package/src/lib/runtime/person-erasure.d.ts +4 -1
  78. package/src/lib/runtime/person-erasure.js +14 -3
  79. package/src/lib/runtime/person-erasure.js.map +1 -1
  80. package/src/lib/runtime/platform-runtime-deps.js +34 -3
  81. package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
  82. package/src/lib/runtime/runtime-deps.d.ts +27 -1
  83. package/src/lib/runtime/runtime-deps.js.map +1 -1
  84. package/src/lib/runtime/send-job.js +46 -6
  85. package/src/lib/runtime/send-job.js.map +1 -1
  86. package/src/lib/runtime/sync-job.js +4 -0
  87. package/src/lib/runtime/sync-job.js.map +1 -1
  88. package/src/lib/storage/domain-intel-store.d.ts +61 -43
  89. package/src/lib/storage/domain-intel-store.js +138 -226
  90. package/src/lib/storage/domain-intel-store.js.map +1 -1
@@ -17,3 +17,13 @@
17
17
  import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
18
18
  import type { OutreachRouteDeps } from './route-deps';
19
19
  export declare function createOutreachEnrollmentActionRoute(deps: OutreachRouteDeps): PluginWebApiHandler;
20
+ /**
21
+ * THE MAIL GATEWAY IN FRONT OF ONE ENROLLED PERSON (AGL-3332):
22
+ * `outreach/enrollments/gateway`, for the Details of their page.
23
+ *
24
+ * The same two reads the Check-people step makes (AGL-3326, AGL-3328): the
25
+ * domain's MX through the platform cache, which no member may read and a
26
+ * lookup refreshes, and that gateway's standing with the domain the
27
+ * enrollment's mailbox sends from. Nothing is written but the cache.
28
+ */
29
+ export declare function createOutreachEnrollmentGatewayRoute(deps: OutreachRouteDeps): PluginWebApiHandler;
@@ -16,8 +16,12 @@
16
16
  */ import { _ as _extends } from "@swc/helpers/_/_extends";
17
17
  import { applyOutreachEnrollmentEvent } from "../engine/enrollment-state.js";
18
18
  import { OUTREACH_OPEN_ENROLLMENT_STATUSES } from "../engine/gates.js";
19
+ import { outreachEmailDomain } from "../engine/do-not-contact-domain.js";
20
+ import { outreachActionHistoryRow, outreachHistoryEntryId } from "../model/enrollment-history.js";
21
+ import { OUTREACH_ENROLLMENT_HISTORY } from "../model/outreach.types.js";
19
22
  import { addOutreachDoNotContact } from "../storage/do-not-contact-store.js";
20
- import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
23
+ import { outreachMailboxSendingDomain, readOutreachDomainIntel, readOutreachGatewayStandings } from "../storage/domain-intel-store.js";
24
+ import { outreachOrgCollection, readStoredOutreachEnrollment, readStoredOutreachMailbox } from "../storage/outreach-records.js";
21
25
  import { outreachRouteGate } from "./route-gate.js";
22
26
  import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocumentId, readOutreachJsonBody } from "./route-http.js";
23
27
  /**
@@ -40,6 +44,11 @@ import { outreachMethodNotAllowed, outreachOk, outreachRefusal, readOutreachDocu
40
44
  * RESUME on an enrollment the engine held for its gateway (AGL-3326) is the
41
45
  * member saying "send it anyway": the hold is stamped released with who and
42
46
  * when, and the gates do not hold that enrollment on the gateway again.
47
+ *
48
+ * Every act that moves the enrollment is also a row of its history
49
+ * (AGL-3332), with who and why, in the same transaction: the enrollment
50
+ * holds only its CURRENT stop, and a resume clears it, so without the row
51
+ * the person's page could not say they were ever paused.
43
52
  */ const ACTIONS = [
44
53
  'pause',
45
54
  'resume',
@@ -121,6 +130,12 @@ export function createOutreachEnrollmentActionRoute(deps) {
121
130
  updatedAtMs: nowMs
122
131
  });
123
132
  transaction.update(ref, written);
133
+ transaction.set(ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)), outreachActionHistoryRow({
134
+ atMs: nowMs,
135
+ action,
136
+ byUid: caller.uid,
137
+ detail
138
+ }));
124
139
  return {
125
140
  enrollment: _extends({}, enrollment, written),
126
141
  error: null,
@@ -194,5 +209,71 @@ export function createOutreachEnrollmentActionRoute(deps) {
194
209
  if (writes) await batch.commit();
195
210
  return writes;
196
211
  }
212
+ /**
213
+ * THE MAIL GATEWAY IN FRONT OF ONE ENROLLED PERSON (AGL-3332):
214
+ * `outreach/enrollments/gateway`, for the Details of their page.
215
+ *
216
+ * The same two reads the Check-people step makes (AGL-3326, AGL-3328): the
217
+ * domain's MX through the platform cache, which no member may read and a
218
+ * lookup refreshes, and that gateway's standing with the domain the
219
+ * enrollment's mailbox sends from. Nothing is written but the cache.
220
+ */ export function createOutreachEnrollmentGatewayRoute(deps) {
221
+ return async (request)=>{
222
+ var _intel_get, _standings_get;
223
+ if (request.method !== 'POST') return outreachMethodNotAllowed('POST');
224
+ const body = await readOutreachJsonBody(request);
225
+ const caller = await outreachRouteGate(request, body['orgId'], deps.gate);
226
+ if (caller instanceof Response) return caller;
227
+ const enrollmentId = readOutreachDocumentId(body['enrollmentId']);
228
+ if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.');
229
+ const firestore = deps.firestore();
230
+ const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId).get();
231
+ const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
232
+ if (!enrollment) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.');
233
+ const domain = outreachEmailDomain(enrollment.email);
234
+ let sendingDomain = null;
235
+ if (enrollment.mailboxId) {
236
+ try {
237
+ const mailbox = await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(enrollment.mailboxId).get();
238
+ sendingDomain = outreachMailboxSendingDomain(readStoredOutreachMailbox(enrollment.mailboxId, mailbox.exists ? mailbox.data() : undefined));
239
+ } catch (error) {
240
+ console.error('[outreach] the enrollment mailbox could not be read for its sending domain', error);
241
+ }
242
+ }
243
+ const input = {
244
+ resolveMx: deps.resolveMx,
245
+ resolveAddress: deps.resolveAddress,
246
+ nowMs: deps.now(),
247
+ sendingDomain
248
+ };
249
+ const [intel, standings] = domain ? await Promise.all([
250
+ readOutreachDomainIntel(firestore, caller.orgId, [
251
+ enrollment.email
252
+ ], input),
253
+ readOutreachGatewayStandings(firestore, caller.orgId, [
254
+ enrollment.email
255
+ ], input).catch((error)=>{
256
+ console.error('[outreach] the gateway standing could not be read', error);
257
+ return new Map();
258
+ })
259
+ ]) : [
260
+ new Map(),
261
+ new Map()
262
+ ];
263
+ const found = (_intel_get = intel.get(enrollment.email)) != null ? _intel_get : null;
264
+ return outreachOk({
265
+ ok: true,
266
+ domain,
267
+ sendingDomain,
268
+ intel: found ? {
269
+ status: found.status,
270
+ mx: found.mx,
271
+ gateway: found.gateway,
272
+ resolvedAtMs: found.resolvedAtMs
273
+ } : null,
274
+ standing: (_standings_get = standings.get(enrollment.email)) != null ? _standings_get : null
275
+ });
276
+ };
277
+ }
197
278
 
198
279
  //# sourceMappingURL=enrollment-routes.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enrollment-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 type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport type {\n OutreachEnrollmentAction,\n OutreachEnrollmentActionResponse,\n} from '../model/outreach-api'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * WHAT A MEMBER DOES TO ONE ENROLLMENT (AGL-2980):\n * `outreach/enrollments/action` — pause, resume, stop, or mark the person\n * do-not-contact.\n *\n * Every transition is the engine's (`applyOutreachEnrollmentEvent`): the\n * route decides nothing about which status may follow which. Each write is\n * a transaction over the one document, so a member's pause and the sending\n * runtime's step completion cannot interleave and leave a paused enrollment\n * with a step it should not have run.\n *\n * MARK DO-NOT-CONTACT puts the address on the organization's list, with\n * the member as its author, and then opts out every enrollment that address\n * has in the organization — this one wherever it stood, and any other that\n * is still active or paused — so no sequence emails it again, whichever one\n * would have been next.\n *\n * RESUME on an enrollment the engine held for its gateway (AGL-3326) is the\n * member saying \"send it anyway\": the hold is stamped released with who and\n * when, and the gates do not hold that enrollment on the gateway again.\n */\n\nconst ACTIONS: readonly OutreachEnrollmentAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\nfunction eventFor(\n action: OutreachEnrollmentAction,\n atMs: number,\n byUid: string,\n detail: string | null,\n): OutreachEnrollmentEvent {\n switch (action) {\n case 'pause':\n return { type: 'pause', atMs, byUid, detail }\n case 'resume':\n return { type: 'resume', atMs, byUid }\n case 'stop':\n return { type: 'stop', atMs, byUid, reason: 'manual', detail }\n case 'do_not_contact':\n return { type: 'opt_out', atMs, reason: 'do_not_contact', detail }\n }\n}\n\nexport function createOutreachEnrollmentActionRoute(deps: OutreachRouteDeps): PluginWebApiHandler {\n return 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 enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n const action = body['action'] as OutreachEnrollmentAction\n if (!enrollmentId || !ACTIONS.includes(action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the enrollment, and pause, resume, stop or mark it do-not-contact.')\n }\n const detail = typeof body['detail'] === 'string' ? body['detail'] : null\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const ref = enrollments.doc(enrollmentId)\n const nowMs = deps.now()\n\n const applied = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return null\n const { patch, error } = applyOutreachEnrollmentEvent(\n enrollment,\n eventFor(action, nowMs, caller.uid, detail),\n )\n if (!patch) return { enrollment, error, changed: false }\n // The opt-out event names no member; the person who marked it is\n // recorded here, beside the reason, and on the list entry.\n const released =\n action === 'resume' && enrollment.stopReason === 'gateway_blocked_here'\n ? {\n gatewayHold: {\n gateway: enrollment.gatewayHold?.gateway ?? 'other',\n heldAtMs: enrollment.gatewayHold?.heldAtMs ?? enrollment.stoppedAtMs,\n releasedByUid: caller.uid,\n releasedAtMs: nowMs,\n },\n }\n : {}\n const written = {\n ...patch,\n ...released,\n stoppedByUid: action === 'do_not_contact' ? caller.uid : patch.stoppedByUid,\n updatedAtMs: nowMs,\n }\n transaction.update(ref, written)\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, error: null, changed: true }\n })\n if (!applied) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n // An address that bounced still goes on the list when a member asks:\n // the enrollment's own status stays final, the list entry is what they\n // asked for.\n if (applied.error && action !== 'do_not_contact') {\n return outreachRefusal(409, 'transition-refused', applied.error)\n }\n\n let stoppedOthers = 0\n if (action === 'do_not_contact') {\n await addOutreachDoNotContact(firestore, {\n orgId: caller.orgId,\n email: applied.enrollment.email,\n reason: 'manual',\n source: 'member',\n addedByUid: caller.uid,\n nowMs,\n enrollmentId,\n sequenceId: applied.enrollment.sequenceId,\n detail,\n })\n stoppedOthers = await optOutOtherEnrollments(firestore, enrollments, applied.enrollment, caller.uid, nowMs)\n // The mark on the record the person is (AGL-3245): the member's verdict\n // stands over every automatic one.\n await deps.stampRecordEmailState({\n orgId: caller.orgId,\n email: applied.enrollment.email,\n state: { status: 'do_not_contact', atMs: nowMs, source: 'member', detail, enrollmentId },\n })\n }\n return outreachOk({\n ok: true,\n changed: applied.changed,\n enrollment: applied.enrollment,\n stoppedOthers,\n } satisfies OutreachEnrollmentActionResponse)\n }\n}\n\n/** Opts out every OTHER open enrollment of the same address; returns how many. */\nasync function optOutOtherEnrollments(\n firestore: FirebaseFirestore.Firestore,\n enrollments: FirebaseFirestore.CollectionReference,\n marked: OutreachEnrollment,\n byUid: string,\n nowMs: number,\n): Promise<number> {\n const found = await enrollments.where('email', '==', marked.email).get()\n const batch = firestore.batch()\n let writes = 0\n for (const doc of found.docs) {\n if (doc.id === marked.id) continue\n const other = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!other || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(other.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(other, {\n type: 'opt_out',\n atMs: nowMs,\n reason: 'do_not_contact',\n detail: 'Marked do-not-contact from another sequence.',\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, stoppedByUid: byUid, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n return writes\n}\n"],"names":["applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","addOutreachDoNotContact","outreachOrgCollection","readStoredOutreachEnrollment","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ACTIONS","eventFor","action","atMs","byUid","detail","type","reason","createOutreachEnrollmentActionRoute","deps","request","method","body","caller","gate","Response","enrollmentId","includes","firestore","enrollments","orgId","ref","doc","nowMs","now","applied","runTransaction","transaction","enrollment","snapshot","get","exists","data","undefined","patch","error","uid","changed","released","stopReason","gatewayHold","gateway","heldAtMs","stoppedAtMs","releasedByUid","releasedAtMs","written","stoppedByUid","updatedAtMs","update","stoppedOthers","email","source","addedByUid","sequenceId","optOutOtherEnrollments","stampRecordEmailState","state","status","ok","marked","found","where","batch","writes","docs","id","other","commit"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/enrollment-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 type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport {\n applyOutreachEnrollmentEvent,\n type OutreachEnrollmentEvent,\n} from '../engine/enrollment-state'\nimport { OUTREACH_OPEN_ENROLLMENT_STATUSES } from '../engine/gates'\nimport { outreachEmailDomain } from '../engine/do-not-contact-domain'\nimport type {\n OutreachEnrollmentAction,\n OutreachEnrollmentActionResponse,\n OutreachEnrollmentGatewayResponse,\n} from '../model/outreach-api'\nimport { outreachActionHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport { OUTREACH_ENROLLMENT_HISTORY, type OutreachEnrollment } from '../model/outreach.types'\nimport { addOutreachDoNotContact } from '../storage/do-not-contact-store'\nimport {\n outreachMailboxSendingDomain,\n readOutreachDomainIntel,\n readOutreachGatewayStandings,\n} from '../storage/domain-intel-store'\nimport {\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n readStoredOutreachMailbox,\n} from '../storage/outreach-records'\nimport type { OutreachRouteDeps } from './route-deps'\nimport { outreachRouteGate } from './route-gate'\nimport {\n outreachMethodNotAllowed,\n outreachOk,\n outreachRefusal,\n readOutreachDocumentId,\n readOutreachJsonBody,\n} from './route-http'\n\n/**\n * WHAT A MEMBER DOES TO ONE ENROLLMENT (AGL-2980):\n * `outreach/enrollments/action` — pause, resume, stop, or mark the person\n * do-not-contact.\n *\n * Every transition is the engine's (`applyOutreachEnrollmentEvent`): the\n * route decides nothing about which status may follow which. Each write is\n * a transaction over the one document, so a member's pause and the sending\n * runtime's step completion cannot interleave and leave a paused enrollment\n * with a step it should not have run.\n *\n * MARK DO-NOT-CONTACT puts the address on the organization's list, with\n * the member as its author, and then opts out every enrollment that address\n * has in the organization — this one wherever it stood, and any other that\n * is still active or paused — so no sequence emails it again, whichever one\n * would have been next.\n *\n * RESUME on an enrollment the engine held for its gateway (AGL-3326) is the\n * member saying \"send it anyway\": the hold is stamped released with who and\n * when, and the gates do not hold that enrollment on the gateway again.\n *\n * Every act that moves the enrollment is also a row of its history\n * (AGL-3332), with who and why, in the same transaction: the enrollment\n * holds only its CURRENT stop, and a resume clears it, so without the row\n * the person's page could not say they were ever paused.\n */\n\nconst ACTIONS: readonly OutreachEnrollmentAction[] = ['pause', 'resume', 'stop', 'do_not_contact']\n\nfunction eventFor(\n action: OutreachEnrollmentAction,\n atMs: number,\n byUid: string,\n detail: string | null,\n): OutreachEnrollmentEvent {\n switch (action) {\n case 'pause':\n return { type: 'pause', atMs, byUid, detail }\n case 'resume':\n return { type: 'resume', atMs, byUid }\n case 'stop':\n return { type: 'stop', atMs, byUid, reason: 'manual', detail }\n case 'do_not_contact':\n return { type: 'opt_out', atMs, reason: 'do_not_contact', detail }\n }\n}\n\nexport function createOutreachEnrollmentActionRoute(deps: OutreachRouteDeps): PluginWebApiHandler {\n return 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 enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n const action = body['action'] as OutreachEnrollmentAction\n if (!enrollmentId || !ACTIONS.includes(action)) {\n return outreachRefusal(400, 'invalid-request', 'Name the enrollment, and pause, resume, stop or mark it do-not-contact.')\n }\n const detail = typeof body['detail'] === 'string' ? body['detail'] : null\n const firestore = deps.firestore()\n const enrollments = outreachOrgCollection(firestore, caller.orgId, 'enrollments')\n const ref = enrollments.doc(enrollmentId)\n const nowMs = deps.now()\n\n const applied = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return null\n const { patch, error } = applyOutreachEnrollmentEvent(\n enrollment,\n eventFor(action, nowMs, caller.uid, detail),\n )\n if (!patch) return { enrollment, error, changed: false }\n // The opt-out event names no member; the person who marked it is\n // recorded here, beside the reason, and on the list entry.\n const released =\n action === 'resume' && enrollment.stopReason === 'gateway_blocked_here'\n ? {\n gatewayHold: {\n gateway: enrollment.gatewayHold?.gateway ?? 'other',\n heldAtMs: enrollment.gatewayHold?.heldAtMs ?? enrollment.stoppedAtMs,\n releasedByUid: caller.uid,\n releasedAtMs: nowMs,\n },\n }\n : {}\n const written = {\n ...patch,\n ...released,\n stoppedByUid: action === 'do_not_contact' ? caller.uid : patch.stoppedByUid,\n updatedAtMs: nowMs,\n }\n transaction.update(ref, written)\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachActionHistoryRow({ atMs: nowMs, action, byUid: caller.uid, detail }),\n )\n return { enrollment: { ...enrollment, ...written } as OutreachEnrollment, error: null, changed: true }\n })\n if (!applied) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n // An address that bounced still goes on the list when a member asks:\n // the enrollment's own status stays final, the list entry is what they\n // asked for.\n if (applied.error && action !== 'do_not_contact') {\n return outreachRefusal(409, 'transition-refused', applied.error)\n }\n\n let stoppedOthers = 0\n if (action === 'do_not_contact') {\n await addOutreachDoNotContact(firestore, {\n orgId: caller.orgId,\n email: applied.enrollment.email,\n reason: 'manual',\n source: 'member',\n addedByUid: caller.uid,\n nowMs,\n enrollmentId,\n sequenceId: applied.enrollment.sequenceId,\n detail,\n })\n stoppedOthers = await optOutOtherEnrollments(firestore, enrollments, applied.enrollment, caller.uid, nowMs)\n // The mark on the record the person is (AGL-3245): the member's verdict\n // stands over every automatic one.\n await deps.stampRecordEmailState({\n orgId: caller.orgId,\n email: applied.enrollment.email,\n state: { status: 'do_not_contact', atMs: nowMs, source: 'member', detail, enrollmentId },\n })\n }\n return outreachOk({\n ok: true,\n changed: applied.changed,\n enrollment: applied.enrollment,\n stoppedOthers,\n } satisfies OutreachEnrollmentActionResponse)\n }\n}\n\n/** Opts out every OTHER open enrollment of the same address; returns how many. */\nasync function optOutOtherEnrollments(\n firestore: FirebaseFirestore.Firestore,\n enrollments: FirebaseFirestore.CollectionReference,\n marked: OutreachEnrollment,\n byUid: string,\n nowMs: number,\n): Promise<number> {\n const found = await enrollments.where('email', '==', marked.email).get()\n const batch = firestore.batch()\n let writes = 0\n for (const doc of found.docs) {\n if (doc.id === marked.id) continue\n const other = readStoredOutreachEnrollment(doc.id, doc.data())\n if (!other || !OUTREACH_OPEN_ENROLLMENT_STATUSES.includes(other.status)) continue\n const { patch } = applyOutreachEnrollmentEvent(other, {\n type: 'opt_out',\n atMs: nowMs,\n reason: 'do_not_contact',\n detail: 'Marked do-not-contact from another sequence.',\n })\n if (!patch) continue\n batch.update(doc.ref, { ...patch, stoppedByUid: byUid, updatedAtMs: nowMs })\n writes += 1\n }\n if (writes) await batch.commit()\n return writes\n}\n\n/**\n * THE MAIL GATEWAY IN FRONT OF ONE ENROLLED PERSON (AGL-3332):\n * `outreach/enrollments/gateway`, for the Details of their page.\n *\n * The same two reads the Check-people step makes (AGL-3326, AGL-3328): the\n * domain's MX through the platform cache, which no member may read and a\n * lookup refreshes, and that gateway's standing with the domain the\n * enrollment's mailbox sends from. Nothing is written but the cache.\n */\nexport function createOutreachEnrollmentGatewayRoute(deps: OutreachRouteDeps): PluginWebApiHandler {\n return 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 enrollmentId = readOutreachDocumentId(body['enrollmentId'])\n if (!enrollmentId) return outreachRefusal(400, 'invalid-request', 'Name the enrollment.')\n const firestore = deps.firestore()\n const snapshot = await outreachOrgCollection(firestore, caller.orgId, 'enrollments').doc(enrollmentId).get()\n const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined)\n if (!enrollment) return outreachRefusal(404, 'enrollment-not-found', 'That enrollment no longer exists.')\n const domain = outreachEmailDomain(enrollment.email)\n let sendingDomain: string | null = null\n if (enrollment.mailboxId) {\n try {\n const mailbox = await outreachOrgCollection(firestore, caller.orgId, 'mailboxes').doc(enrollment.mailboxId).get()\n sendingDomain = outreachMailboxSendingDomain(\n readStoredOutreachMailbox(enrollment.mailboxId, mailbox.exists ? mailbox.data() : undefined),\n )\n } catch (error) {\n console.error('[outreach] the enrollment mailbox could not be read for its sending domain', error)\n }\n }\n const input = {\n resolveMx: deps.resolveMx,\n resolveAddress: deps.resolveAddress,\n nowMs: deps.now(),\n sendingDomain,\n }\n const [intel, standings] = domain\n ? await Promise.all([\n readOutreachDomainIntel(firestore, caller.orgId, [enrollment.email], input),\n readOutreachGatewayStandings(firestore, caller.orgId, [enrollment.email], input).catch(\n (error: unknown) => {\n console.error('[outreach] the gateway standing could not be read', error)\n return new Map()\n },\n ),\n ])\n : [new Map(), new Map()]\n const found = intel.get(enrollment.email) ?? null\n return outreachOk({\n ok: true,\n domain,\n sendingDomain,\n intel: found\n ? { status: found.status, mx: found.mx, gateway: found.gateway, resolvedAtMs: found.resolvedAtMs }\n : null,\n standing: standings.get(enrollment.email) ?? null,\n } satisfies OutreachEnrollmentGatewayResponse)\n }\n}\n"],"names":["applyOutreachEnrollmentEvent","OUTREACH_OPEN_ENROLLMENT_STATUSES","outreachEmailDomain","outreachActionHistoryRow","outreachHistoryEntryId","OUTREACH_ENROLLMENT_HISTORY","addOutreachDoNotContact","outreachMailboxSendingDomain","readOutreachDomainIntel","readOutreachGatewayStandings","outreachOrgCollection","readStoredOutreachEnrollment","readStoredOutreachMailbox","outreachRouteGate","outreachMethodNotAllowed","outreachOk","outreachRefusal","readOutreachDocumentId","readOutreachJsonBody","ACTIONS","eventFor","action","atMs","byUid","detail","type","reason","createOutreachEnrollmentActionRoute","deps","request","method","body","caller","gate","Response","enrollmentId","includes","firestore","enrollments","orgId","ref","doc","nowMs","now","applied","runTransaction","transaction","enrollment","snapshot","get","exists","data","undefined","patch","error","uid","changed","released","stopReason","gatewayHold","gateway","heldAtMs","stoppedAtMs","releasedByUid","releasedAtMs","written","stoppedByUid","updatedAtMs","update","set","collection","stoppedOthers","email","source","addedByUid","sequenceId","optOutOtherEnrollments","stampRecordEmailState","state","status","ok","marked","found","where","batch","writes","docs","id","other","commit","createOutreachEnrollmentGatewayRoute","intel","standings","domain","sendingDomain","mailboxId","mailbox","console","input","resolveMx","resolveAddress","Promise","all","catch","Map","mx","resolvedAtMs","standing"],"mappings":"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"}
@@ -17,7 +17,7 @@
17
17
  import { pluginTextGenerator } from "@aglyn/aglyn/plugin-manager/plugin-text-generation";
18
18
  import { registerPluginApiRoute } from "@aglyn/aglyn/server";
19
19
  import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
20
- import { resolveMx } from "node:dns/promises";
20
+ import { platformMailDnsResolver } from "@aglyn/tenant-data-admin/server/email-deliverability";
21
21
  import { OUTREACH_API_ROUTES } from "../constants/api-routes.js";
22
22
  import { openOutreachMailboxClient } from "../mailboxes/mailbox-transport.js";
23
23
  import { outreachOrgSubject } from "../mailboxes/register-mailbox-routes.js";
@@ -25,7 +25,7 @@ import { outreachShortLinkUrl } from "../runtime/click-link.js";
25
25
  import { createOutreachCurateRoutes } from "./curate-routes.js";
26
26
  import { createOutreachDoNotContactDomainsRoute } from "./do-not-contact-routes.js";
27
27
  import { createOutreachEnrollRoutes } from "./enroll-routes.js";
28
- import { createOutreachEnrollmentActionRoute } from "./enrollment-routes.js";
28
+ import { createOutreachEnrollmentActionRoute, createOutreachEnrollmentGatewayRoute } from "./enrollment-routes.js";
29
29
  import { canonicalConsoleOrigin } from "../mailboxes/oauth-redirect.js";
30
30
  import { createOutreachLinkDomainsRoute } from "./link-domain-routes.js";
31
31
  import { createOutreachPreviewRoute } from "./preview-routes.js";
@@ -85,8 +85,15 @@ export function defaultOutreachRouteDeps() {
85
85
  var _pluginRecordTimelineWriter;
86
86
  return (_ref = (_pluginRecordTimelineWriter = pluginRecordTimelineWriter()) == null ? void 0 : _pluginRecordTimelineWriter.writer) != null ? _ref : null;
87
87
  },
88
- // The domain's MX (AGL-3326), from the console's own resolver.
89
- resolveMx: (domain)=>resolveMx(domain),
88
+ // The domain's MX (AGL-3326) and, for a domain with none, its address
89
+ // record — through the platform's pinned resolver (AGL-3328), which
90
+ // asks twice before it says a domain takes no mail.
91
+ resolveMx: (domain)=>platformMailDnsResolver().resolveMx(domain),
92
+ resolveAddress: (domain)=>{
93
+ var _ref;
94
+ var _platformMailDnsResolver_resolveAddress, _platformMailDnsResolver;
95
+ return (_ref = (_platformMailDnsResolver_resolveAddress = (_platformMailDnsResolver = platformMailDnsResolver()).resolveAddress) == null ? void 0 : _platformMailDnsResolver_resolveAddress.call(_platformMailDnsResolver, domain)) != null ? _ref : Promise.resolve(false);
96
+ },
90
97
  // The workspace's text generator (AGL-3324), resolved per call the same
91
98
  // way: the AI plugin registers at boot, and a request before it did is
92
99
  // told drafting is unavailable rather than caching a `null`.
@@ -157,6 +164,9 @@ export function registerOutreachRoutes(deps = defaultOutreachRouteDeps()) {
157
164
  registerPluginApiRoute(OUTREACH_API_ROUTES.enrollmentsAction, {
158
165
  web: createOutreachEnrollmentActionRoute(deps)
159
166
  }, orgSubject);
167
+ registerPluginApiRoute(OUTREACH_API_ROUTES.enrollmentsGateway, {
168
+ web: createOutreachEnrollmentGatewayRoute(deps)
169
+ }, orgSubject);
160
170
  registerPluginApiRoute(OUTREACH_API_ROUTES.preview, {
161
171
  web: createOutreachPreviewRoute(deps)
162
172
  }, orgSubject);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { pluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveMx } from 'node:dns/promises'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { outreachShortLinkUrl } from '../runtime/click-link'\nimport { createOutreachCurateRoutes } from './curate-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport { createOutreachEnrollmentActionRoute } from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\nimport { createOutreachStepTestRoute, type OutreachStepTestDeps } from './step-test-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n // The domain's MX (AGL-3326), from the console's own resolver.\n resolveMx: (domain) => resolveMx(domain),\n // The workspace's text generator (AGL-3324), resolved per call the same\n // way: the AI plugin registers at boot, and a request before it did is\n // told drafting is unavailable rather than caching a `null`.\n textGenerator: () => pluginTextGenerator()?.generator ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\n/**\n * The test send's reach (AGL-3325): the mailbox's Gmail through the same\n * door the runtime and the Mailboxes test use, the platform's rate limit,\n * and the short links a real send would mint.\n */\nexport function defaultOutreachStepTestDeps(): OutreachStepTestDeps {\n return {\n openMailbox: (mailboxId) => openOutreachMailboxClient(firebaseAdmin.app().firestore(), { mailboxId }),\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n clickLinkOrigin: async ({ orgId, senderAddress }) =>\n (await platform()).resolveTrackingLinkOrigin(firebaseAdmin.app().firestore(), orgId, senderAddress),\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const curate = createOutreachCurateRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateDraft, { web: curate.draft }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateSave, { web: curate.save }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.stepTest,\n { web: createOutreachStepTestRoute(deps, defaultOutreachStepTestDeps()) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","pluginTextGenerator","registerPluginApiRoute","firebaseAdmin","resolveMx","OUTREACH_API_ROUTES","openOutreachMailboxClient","outreachOrgSubject","outreachShortLinkUrl","createOutreachCurateRoutes","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","createOutreachStepTestRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","domain","textGenerator","generator","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","defaultOutreachStepTestDeps","openMailbox","mailboxId","consumeRateLimit","clickLinkUrl","linkId","trackingOrigin","origin","clickLinkOrigin","senderAddress","resolveTrackingLinkOrigin","registerOutreachRoutes","deps","sequences","enroll","curate","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","curateDraft","draft","curateSave","stepTest","doNotContactDomains","linkDomains"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/register-routes.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { pluginTextGenerator } from '@aglyn/aglyn/plugin-manager/plugin-text-generation'\nimport { registerPluginApiRoute } from '@aglyn/aglyn/server'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { platformMailDnsResolver } from '@aglyn/tenant-data-admin/server/email-deliverability'\nimport { OUTREACH_API_ROUTES } from '../constants/api-routes'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { outreachOrgSubject } from '../mailboxes/register-mailbox-routes'\nimport { outreachShortLinkUrl } from '../runtime/click-link'\nimport { createOutreachCurateRoutes } from './curate-routes'\nimport { createOutreachDoNotContactDomainsRoute } from './do-not-contact-routes'\nimport { createOutreachEnrollRoutes, type OutreachEnrollRouteDeps } from './enroll-routes'\nimport {\n createOutreachEnrollmentActionRoute,\n createOutreachEnrollmentGatewayRoute,\n} from './enrollment-routes'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport { createOutreachLinkDomainsRoute, type OutreachLinkDomainRouteDeps } from './link-domain-routes'\nimport { createOutreachPreviewRoute } from './preview-routes'\nimport type { OutreachRouteGateDeps } from './route-gate'\nimport { createOutreachSequenceRoutes } from './sequence-routes'\nimport { createOutreachSettingsRoute } from './settings-routes'\nimport { createOutreachStepTestRoute, type OutreachStepTestDeps } from './step-test-routes'\n\n/**\n * Wires the settings, sequence and enrollment routes into the console\n * dispatcher (AGL-2980) with the platform's own dependencies. Specs build\n * their own.\n *\n * Every one names its organization (`orgId`, in the query or the JSON body)\n * and none names a site, so each declares the mailbox routes' subject reader:\n * the dispatcher's release gate then asks about that organization, and a\n * `release_outreach` override for one organization reaches these routes as it\n * reaches Mailboxes. Without it the gate reads the request as anonymous, which\n * only a fully enabled flag or a staff session passes.\n */\n\n/** The platform's heavier modules, loaded the first time a request needs one. */\nconst platform = () => import('./platform-deps')\n\n/** The gate's reach into the platform. */\nexport function defaultOutreachRouteGateDeps(): OutreachRouteGateDeps {\n return {\n verifyIdToken: (idToken) => firebaseAdmin.app().auth().verifyIdToken(idToken),\n resolveOrgPermissions: async (uid, context) => (await platform()).resolveOrgPermissions(uid, context),\n holdsOrgCatalogPermission: async (uid, orgId, key) =>\n (await platform()).holdsOrgCatalogPermission(uid, orgId, key),\n readOrg: async (orgId) => {\n const snapshot = await firebaseAdmin.app().firestore().collection('orgs').doc(orgId).get()\n return snapshot.exists ? (snapshot.data() ?? {}) : null\n },\n lockdownRefusal: async (options) => (await platform()).lockdownRefusal(options),\n }\n}\n\nexport function defaultOutreachRouteDeps(): OutreachEnrollRouteDeps {\n return {\n firestore: () => firebaseAdmin.app().firestore(),\n gate: defaultOutreachRouteGateDeps(),\n now: Date.now,\n random: Math.random,\n logOrgActivity: async (orgId, actor, action, target) =>\n (await platform()).logOrgActivity(orgId, actor, action, target),\n crmViewEmails: async (input) => (await platform()).crmViewEmails(input),\n stampRecordEmailState: async (stamp) => {\n await (await platform()).stampRecordEmailState(stamp)\n },\n creditCampaign: async ({ hostId, orgId, campaignIds, outcome, atMs }) => {\n await (await platform()).creditCampaignSequenceOutcome(\n { hostId, orgId, campaignIds, outcome, atMs },\n firebaseAdmin.app().firestore(),\n )\n },\n // The record system's writer (AGL-3274), resolved per call the way the\n // runtime resolves it: the CRM registers at boot, and a request that\n // arrives before it did files nothing rather than caching a `null`.\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n // The domain's MX (AGL-3326) and, for a domain with none, its address\n // record — through the platform's pinned resolver (AGL-3328), which\n // asks twice before it says a domain takes no mail.\n resolveMx: (domain) => platformMailDnsResolver().resolveMx(domain),\n resolveAddress: (domain) => platformMailDnsResolver().resolveAddress?.(domain) ?? Promise.resolve(false),\n // The workspace's text generator (AGL-3324), resolved per call the same\n // way: the AI plugin registers at boot, and a request before it did is\n // told drafting is unavailable rather than caching a `null`.\n textGenerator: () => pluginTextGenerator()?.generator ?? null,\n }\n}\n\n/** The link domains route's reach (AGL-3306): the tracking-host store, loaded on first use. */\nexport function defaultOutreachLinkDomainDeps(): OutreachLinkDomainRouteDeps {\n return {\n hosts: async () => {\n const loaded = await platform()\n return {\n list: loaded.listTrackingHosts,\n setUp: loaded.setUpTrackingHost,\n verify: loaded.verifyTrackingHost,\n remove: loaded.removeTrackingHost,\n }\n },\n consoleOrigin: canonicalConsoleOrigin,\n }\n}\n\n/**\n * The test send's reach (AGL-3325): the mailbox's Gmail through the same\n * door the runtime and the Mailboxes test use, the platform's rate limit,\n * and the short links a real send would mint.\n */\nexport function defaultOutreachStepTestDeps(): OutreachStepTestDeps {\n return {\n openMailbox: (mailboxId) => openOutreachMailboxClient(firebaseAdmin.app().firestore(), { mailboxId }),\n consumeRateLimit: async (key, options) =>\n (await import('@aglyn/tenant-data-admin/server/rate-limit-store')).consumeRateLimit(key, options),\n clickLinkUrl: (linkId, trackingOrigin) =>\n outreachShortLinkUrl({ origin: canonicalConsoleOrigin(), linkId, trackingOrigin }),\n clickLinkOrigin: async ({ orgId, senderAddress }) =>\n (await platform()).resolveTrackingLinkOrigin(firebaseAdmin.app().firestore(), orgId, senderAddress),\n }\n}\n\nexport function registerOutreachRoutes(\n deps: OutreachEnrollRouteDeps = defaultOutreachRouteDeps(),\n): void {\n const sequences = createOutreachSequenceRoutes(deps)\n const enroll = createOutreachEnrollRoutes(deps)\n const curate = createOutreachCurateRoutes(deps)\n const orgSubject = { subject: outreachOrgSubject }\n registerPluginApiRoute(OUTREACH_API_ROUTES.settings, { web: createOutreachSettingsRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesSave, { web: sequences.save }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesStatus, { web: sequences.status }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.sequencesDelete, { web: sequences.remove }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enrollPreview, { web: enroll.preview }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.enroll, { web: enroll.confirm }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsAction,\n { web: createOutreachEnrollmentActionRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.enrollmentsGateway,\n { web: createOutreachEnrollmentGatewayRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(OUTREACH_API_ROUTES.preview, { web: createOutreachPreviewRoute(deps) }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateDraft, { web: curate.draft }, orgSubject)\n registerPluginApiRoute(OUTREACH_API_ROUTES.curateSave, { web: curate.save }, orgSubject)\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.stepTest,\n { web: createOutreachStepTestRoute(deps, defaultOutreachStepTestDeps()) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.doNotContactDomains,\n { web: createOutreachDoNotContactDomainsRoute(deps) },\n orgSubject,\n )\n registerPluginApiRoute(\n OUTREACH_API_ROUTES.linkDomains,\n { web: createOutreachLinkDomainsRoute(deps, defaultOutreachLinkDomainDeps()) },\n orgSubject,\n )\n}\n"],"names":["pluginRecordTimelineWriter","pluginTextGenerator","registerPluginApiRoute","firebaseAdmin","platformMailDnsResolver","OUTREACH_API_ROUTES","openOutreachMailboxClient","outreachOrgSubject","outreachShortLinkUrl","createOutreachCurateRoutes","createOutreachDoNotContactDomainsRoute","createOutreachEnrollRoutes","createOutreachEnrollmentActionRoute","createOutreachEnrollmentGatewayRoute","canonicalConsoleOrigin","createOutreachLinkDomainsRoute","createOutreachPreviewRoute","createOutreachSequenceRoutes","createOutreachSettingsRoute","createOutreachStepTestRoute","platform","defaultOutreachRouteGateDeps","verifyIdToken","idToken","app","auth","resolveOrgPermissions","uid","context","holdsOrgCatalogPermission","orgId","key","readOrg","snapshot","firestore","collection","doc","get","exists","data","lockdownRefusal","options","defaultOutreachRouteDeps","gate","now","Date","random","Math","logOrgActivity","actor","action","target","crmViewEmails","input","stampRecordEmailState","stamp","creditCampaign","hostId","campaignIds","outcome","atMs","creditCampaignSequenceOutcome","timeline","writer","resolveMx","domain","resolveAddress","Promise","resolve","textGenerator","generator","defaultOutreachLinkDomainDeps","hosts","loaded","list","listTrackingHosts","setUp","setUpTrackingHost","verify","verifyTrackingHost","remove","removeTrackingHost","consoleOrigin","defaultOutreachStepTestDeps","openMailbox","mailboxId","consumeRateLimit","clickLinkUrl","linkId","trackingOrigin","origin","clickLinkOrigin","senderAddress","resolveTrackingLinkOrigin","registerOutreachRoutes","deps","sequences","enroll","curate","orgSubject","subject","settings","web","sequencesSave","save","sequencesStatus","status","sequencesDelete","enrollPreview","preview","confirm","enrollmentsAction","enrollmentsGateway","curateDraft","draft","curateSave","stepTest","doNotContactDomains","linkDomains"],"mappings":"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"}
@@ -60,7 +60,9 @@ export interface OutreachRouteDeps {
60
60
  creditCampaign: OutreachCampaignCredit['credit'];
61
61
  /**
62
62
  * The MX resolver the enroll routes classify a domain's mail gateway with
63
- * (AGL-3326) — Node's `dns.promises.resolveMx` on the platform.
63
+ * (AGL-3326) — the platform's pinned resolver (`platformMailDnsResolver`).
64
64
  */
65
65
  resolveMx: OutreachResolveMx;
66
+ /** Whether a domain with no MX has an address record (AGL-3328); see the runtime's twin. */
67
+ resolveAddress?: (domain: string) => Promise<boolean>;
66
68
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachCampaignCredit, OutreachRecordEmailStamp } from '../runtime/runtime-deps'\nimport type { OutreachResolveMx } from '../storage/domain-intel-store'\nimport type { OutreachRouteGateDeps } from './route-gate'\n\n/**\n * What the settings, sequence and enrollment routes reach outside this\n * plugin (AGL-2980). The platform's own are `register-routes.ts`; specs\n * build their own.\n */\n\n/**\n * The org activity target a sequence's rows are filed under: Outreach's own\n * namespaced resource (`pluginId:noun`), which the feed reads as \"Sequence\".\n * Core's target list names only core's resources, so a plugin's never joins\n * it.\n */\nexport const OUTREACH_SEQUENCE_ACTIVITY_TARGET = 'outreach:sequence'\n\n/** What an org activity line written by these routes points at. */\nexport type OutreachActivityTarget =\n | { type: 'org'; id: string }\n | { type: typeof OUTREACH_SEQUENCE_ACTIVITY_TARGET; id: string; name: string }\n\nexport interface OutreachRouteDeps {\n firestore(): FirebaseFirestore.Firestore\n gate: OutreachRouteGateDeps\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n logOrgActivity(\n orgId: string,\n actor: { uid: string; email?: string | null },\n action: string,\n target: OutreachActivityTarget,\n ): Promise<void>\n /**\n * Stamps a member's do-not-contact mark on the lead and the contact that\n * carry the address (AGL-3245); the runtime's twin is on its own deps.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Credits an enrollment to the sequence's campaigns (AGL-3254) — the\n * enroll route's `enrolled`; the runtime's twin credits the rest.\n */\n creditCampaign: OutreachCampaignCredit['credit']\n /**\n * The MX resolver the enroll routes classify a domain's mail gateway with\n * (AGL-3326) — Node's `dns.promises.resolveMx` on the platform.\n */\n resolveMx: OutreachResolveMx\n}\n"],"names":["OUTREACH_SEQUENCE_ACTIVITY_TARGET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD;;;;CAIC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,oCAAoC,oBAAmB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/routes/route-deps.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { OutreachCampaignCredit, OutreachRecordEmailStamp } from '../runtime/runtime-deps'\nimport type { OutreachResolveMx } from '../storage/domain-intel-store'\nimport type { OutreachRouteGateDeps } from './route-gate'\n\n/**\n * What the settings, sequence and enrollment routes reach outside this\n * plugin (AGL-2980). The platform's own are `register-routes.ts`; specs\n * build their own.\n */\n\n/**\n * The org activity target a sequence's rows are filed under: Outreach's own\n * namespaced resource (`pluginId:noun`), which the feed reads as \"Sequence\".\n * Core's target list names only core's resources, so a plugin's never joins\n * it.\n */\nexport const OUTREACH_SEQUENCE_ACTIVITY_TARGET = 'outreach:sequence'\n\n/** What an org activity line written by these routes points at. */\nexport type OutreachActivityTarget =\n | { type: 'org'; id: string }\n | { type: typeof OUTREACH_SEQUENCE_ACTIVITY_TARGET; id: string; name: string }\n\nexport interface OutreachRouteDeps {\n firestore(): FirebaseFirestore.Firestore\n gate: OutreachRouteGateDeps\n now(): number\n /** A source of numbers in `[0, 1)` — the scheduler's jitter. */\n random(): number\n logOrgActivity(\n orgId: string,\n actor: { uid: string; email?: string | null },\n action: string,\n target: OutreachActivityTarget,\n ): Promise<void>\n /**\n * Stamps a member's do-not-contact mark on the lead and the contact that\n * carry the address (AGL-3245); the runtime's twin is on its own deps.\n */\n stampRecordEmailState(stamp: OutreachRecordEmailStamp): Promise<void>\n /**\n * Credits an enrollment to the sequence's campaigns (AGL-3254) — the\n * enroll route's `enrolled`; the runtime's twin credits the rest.\n */\n creditCampaign: OutreachCampaignCredit['credit']\n /**\n * The MX resolver the enroll routes classify a domain's mail gateway with\n * (AGL-3326) — the platform's pinned resolver (`platformMailDnsResolver`).\n */\n resolveMx: OutreachResolveMx\n /** Whether a domain with no MX has an address record (AGL-3328); see the runtime's twin. */\n resolveAddress?: (domain: string) => Promise<boolean>\n}\n"],"names":["OUTREACH_SEQUENCE_ACTIVITY_TARGET"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAMD;;;;CAIC,GAED;;;;;CAKC,GACD,OAAO,MAAMA,oCAAoC,oBAAmB"}
@@ -27,15 +27,20 @@ import type { OutreachRuntimeDeps } from './runtime-deps';
27
27
  * redirect needs nothing from Firestore to answer it, and a failure here
28
28
  * costs a number rather than a visit.
29
29
  *
30
- * Three places are written, because three different questions are asked of
31
- * them and no one document answers all three:
30
+ * Four places are written, because four different questions are asked of
31
+ * them and no one document answers all of them:
32
32
  *
33
33
  * 1. **The enrollment** — "did THIS person act?", which the enrollments
34
- * table shows per row and a rep reads before calling someone.
35
- * 2. **The sequence** — "is this sequence working?", which is the report
34
+ * table shows per row and a rep reads before calling someone: totals,
35
+ * and the distinct destinations they followed.
36
+ * 2. **The enrollment's history** (AGL-3332) — "what exactly did they do,
37
+ * and when?", one row per visit: the destination, the step whose email
38
+ * carried it, and whether a person or a scanner made it. Read only when
39
+ * someone opens the person, so the table's listener never carries it.
40
+ * 3. **The sequence** — "is this sequence working?", which is the report
36
41
  * card, and which cannot be derived from the enrollments because the
37
42
  * console lists them a page at a time.
38
- * 3. **The link rollup** — "which of our links did they follow?", one
43
+ * 4. **The link rollup** — "which of our links did they follow?", one
39
44
  * document holding a bounded map, keyed and capped by the campaign
40
45
  * rollup's own rules so a link means the same thing in both reports.
41
46
  */
@@ -19,7 +19,8 @@ import { FieldValue } from "firebase-admin/firestore";
19
19
  import { OUTREACH_PLUGIN_ID } from "../constants/bundle-common.js";
20
20
  import { judgeOutreachClick } from "../engine/click-tracking.js";
21
21
  import { readOutreachEngagement } from "../model/enrollment-engagement.js";
22
- import { OUTREACH_LINK_ROLLUP_PATH } from "../model/outreach.types.js";
22
+ import { outreachClickHistoryRow, outreachHistoryEntryId } from "../model/enrollment-history.js";
23
+ import { OUTREACH_ENGAGEMENT_LINKS_MAX, OUTREACH_ENROLLMENT_HISTORY, OUTREACH_ENROLLMENT_HISTORY_MAX, OUTREACH_LINK_ROLLUP_PATH } from "../model/outreach.types.js";
23
24
  import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
24
25
  /** When the step that carried the link was sent, or `null` when it cannot be found. */ function sentAtMs(enrollment, stepIndex) {
25
26
  var _enrollment_stepRecords;
@@ -61,7 +62,7 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
61
62
  const nowMs = deps.now();
62
63
  const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId);
63
64
  const outcome = await firestore.runTransaction(async (transaction)=>{
64
- var _held_firstClickAtMs, _campaignLinkKey;
65
+ var _held_firstClickAtMs;
65
66
  const snapshot = await transaction.get(ref);
66
67
  const enrollment = readStoredOutreachEnrollment(enrollmentId, snapshot.exists ? snapshot.data() : undefined);
67
68
  if (!enrollment) {
@@ -80,23 +81,61 @@ import { outreachEnrollmentLink, outreachOrgCollection, readStoredOutreachEnroll
80
81
  });
81
82
  const held = readOutreachEngagement(enrollment.engagement);
82
83
  const first = judgement.human && held.firstClickAtMs === null;
84
+ const key = campaignLinkKey(url);
85
+ // A row for this visit, while the person's history has room for one.
86
+ const logged = held.loggedClicks + held.loggedMachineClicks < OUTREACH_ENROLLMENT_HISTORY_MAX;
87
+ /*
88
+ * The destinations known to have been followed. A click from before the
89
+ * per-click history (AGL-3332) is known only by `lastClickUrl`, which the
90
+ * next click replaces — so it joins the list first, or the person would
91
+ * stop being found by the "Link followed" filter for the link they did
92
+ * follow.
93
+ */ const known = held.lastClickUrl && !held.links.includes(held.lastClickUrl) && held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX ? [
94
+ ...held.links,
95
+ held.lastClickUrl
96
+ ] : held.links;
83
97
  const engagement = judgement.human ? {
84
98
  clicks: held.clicks + 1,
85
99
  firstClickAtMs: (_held_firstClickAtMs = held.firstClickAtMs) != null ? _held_firstClickAtMs : nowMs,
86
100
  lastClickAtMs: nowMs,
87
- lastClickUrl: (_campaignLinkKey = campaignLinkKey(url)) != null ? _campaignLinkKey : held.lastClickUrl,
88
- machineClicks: held.machineClicks
101
+ lastClickUrl: key != null ? key : held.lastClickUrl,
102
+ machineClicks: held.machineClicks,
103
+ links: key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX ? [
104
+ ...known,
105
+ key
106
+ ] : known,
107
+ loggedClicks: held.loggedClicks + (logged ? 1 : 0),
108
+ loggedMachineClicks: held.loggedMachineClicks
89
109
  } : _extends({}, held, {
90
- machineClicks: held.machineClicks + 1
110
+ machineClicks: held.machineClicks + 1,
111
+ loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0)
91
112
  });
92
113
  /*
93
114
  * `updatedAtMs` is deliberately NOT touched. It is what the enrollments
94
115
  * table sorts "last activity" by and what a member reads as "something
95
116
  * happened to this enrollment", and a scanner's fetch of a link is not
96
117
  * that. The engagement carries its own timestamps for what a click is.
97
- */ transaction.update(ref, {
118
+ */ // A person's click is what the "Clicked" filter asks about (AGL-3332); a scanner's is not.
119
+ transaction.update(ref, judgement.human ? {
120
+ engagement,
121
+ clicked: true
122
+ } : {
98
123
  engagement
99
124
  });
125
+ /*
126
+ * The row is written in the same transaction as the count it itemizes,
127
+ * so `loggedClicks` is exactly the number of rows: the detail view says
128
+ * how many clicks have none — before the history began — from the two
129
+ * numbers alone, and never invents a row for one of them.
130
+ */ if (logged) {
131
+ transaction.set(ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)), outreachClickHistoryRow({
132
+ atMs: nowMs,
133
+ url: key,
134
+ stepIndex,
135
+ human: judgement.human,
136
+ machineReason: judgement.machineReason
137
+ }));
138
+ }
100
139
  return _extends({}, judgement, {
101
140
  first,
102
141
  enrollment: _extends({}, enrollment, {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport {\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Three places are written, because three different questions are asked of\n * them and no one document answers all three:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone.\n * 2. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 3. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: campaignLinkKey(url) ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n }\n : { ...held, machineClicks: held.machineClicks + 1 }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n transaction.update(ref, { engagement })\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","clicks","lastClickAtMs","lastClickUrl","machineClicks","update","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","key","collection","set","unattributedClicks","merge","links","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,SACEA,wBAAwB,EACxBC,eAAe,QACV,yCAAwC;AAC/C,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,kBAAkB,QAAQ,gCAA4B;AAC/D,SACEC,kBAAkB,QAEb,8BAA0B;AACjC,SAASC,sBAAsB,QAAQ,oCAAgC;AACvE,SACEC,yBAAyB,QAGpB,6BAAyB;AAChC,SACEC,sBAAsB,EACtBC,qBAAqB,EACrBC,4BAA4B,QACvB,iCAA6B;AAkCpC,qFAAqF,GACrF,SAASC,SAASC,UAA8B,EAAEC,SAAiB;QACjDD;IAAhB,MAAME,WAAUF,0BAAAA,WAAWG,WAAW,YAAtBH,0BAA0B,EAAE;IAC5C,IAAK,IAAII,QAAQF,QAAQG,MAAM,GAAG,GAAGD,SAAS,GAAGA,SAAS,EAAG;QAC3D,MAAME,SAASJ,OAAO,CAACE,MAAM;QAC7B,IAAIE,CAAAA,0BAAAA,OAAQL,SAAS,MAAKA,aAAaK,OAAOC,IAAI,KAAK,SAAS;YAC9D,OAAO,OAAOD,OAAOE,IAAI,KAAK,YAAYC,OAAOC,QAAQ,CAACJ,OAAOE,IAAI,IAAIF,OAAOE,IAAI,GAAG;QACzF;IACF;IACA,OAAO;AACT;AAEA;;;;;;;;;;;;;CAaC,GACD,OAAO,eAAeG,oBACpBC,IAAiE,EACjEC,KAIC;IAED,MAAMC,YAAYF,KAAKE,SAAS;IAChC,MAAM,EAAEC,KAAK,EAAEC,YAAY,EAAEf,SAAS,EAAEgB,GAAG,EAAE,GAAGJ,MAAMK,MAAM;IAC5D,qEAAqE;IACrE,yEAAyE;IACzE,IAAIL,MAAMK,MAAM,CAACC,IAAI,EAAE;QACrB,OAAO;YAAEC,OAAO;YAAOC,eAAe;YAAMC,OAAO;YAAOtB,YAAY;QAAK;IAC7E;IACA,MAAMuB,QAAQX,KAAKY,GAAG;IACtB,MAAMC,MAAM5B,sBAAsBiB,WAAWC,OAAO,eAAeW,GAAG,CAACV;IAEvE,MAAMW,UAAU,MAAMb,UAAUc,cAAc,CAAC,OAAOC;YAoB9BC,sBAEFxC;QArBpB,MAAMyC,WAAW,MAAMF,YAAYG,GAAG,CAACP;QACvC,MAAMzB,aAAaF,6BACjBkB,cACAe,SAASE,MAAM,GAAGF,SAASG,IAAI,KAAKC;QAEtC,IAAI,CAACnC,YAAY;YACf,OAAO;gBAAEoB,OAAO;gBAAOC,eAAe;gBAAMC,OAAO;gBAAOtB,YAAY;YAAK;QAC7E;QACA,MAAMoC,OAAOrC,SAASC,YAAYC;QAClC,MAAMoC,YAAY5C,mBAAmB;YACnC6C,QAAQzB,MAAMyB,MAAM;YACpBC,WAAW1B,MAAM0B,SAAS;YAC1BC,aAAaJ,SAAS,OAAO,OAAOb,QAAQa;QAC9C;QACA,MAAMN,OAAOpC,uBAAuBM,WAAWyC,UAAU;QACzD,MAAMnB,QAAQe,UAAUjB,KAAK,IAAIU,KAAKY,cAAc,KAAK;QACzD,MAAMD,aAA2CJ,UAAUjB,KAAK,GAC5D;YACEuB,QAAQb,KAAKa,MAAM,GAAG;YACtBD,cAAc,GAAEZ,uBAAAA,KAAKY,cAAc,YAAnBZ,uBAAuBP;YACvCqB,eAAerB;YACfsB,YAAY,GAAEvD,mBAAAA,gBAAgB2B,gBAAhB3B,mBAAwBwC,KAAKe,YAAY;YACvDC,eAAehB,KAAKgB,aAAa;QACnC,IACA,aAAKhB;YAAMgB,eAAehB,KAAKgB,aAAa,GAAG;;QACnD;;;;;KAKC,GACDjB,YAAYkB,MAAM,CAACtB,KAAK;YAAEgB;QAAW;QACrC,OAAO,aAAKJ;YAAWf;YAAOtB,YAAY,aAAKA;gBAAYyC;;;IAC7D;IAEA,IAAI,CAACd,QAAQ3B,UAAU,EAAE,OAAO2B;IAEhC,MAAMqB,QAAQC,GAAG,CAAC;QAChBC,kBAAkBpC,WAAWa,SAAS;YACpCZ;YACAoC,YAAYxB,QAAQ3B,UAAU,CAACmD,UAAU;YACzC5B;QACF;QACAI,QAAQP,KAAK,GAAGgC,eAAetC,WAAW;YAAEC;YAAOoC,YAAYxB,QAAQ3B,UAAU,CAACmD,UAAU;YAAElC;QAAI,KAAK;QACvGU,QAAQL,KAAK,GAAG+B,eAAezC,MAAM;YAAEG;YAAOf,YAAY2B,QAAQ3B,UAAU;YAAEiB;YAAKM;QAAM,KAAK;KAC/F;IACD,OAAOI;AACT;AAEA;;;;;;;CAOC,GACD,eAAeuB,kBACbpC,SAAsC,EACtCa,OAA6B,EAC7Bd,KAA2D;IAE3D,IAAI,CAACA,MAAMsC,UAAU,EAAE;IACvB,MAAMG,QAAiC3B,QAAQP,KAAK,GAChD;QACE,gBAAgB7B,WAAWgE,SAAS,CAAC;QACrC,uBAAuB1C,MAAMU,KAAK;OAC9BI,QAAQL,KAAK,GAAG;QAAE,sBAAsB/B,WAAWgE,SAAS,CAAC;IAAG,IAAI,CAAC,KAE3E;QAAE,uBAAuBhE,WAAWgE,SAAS,CAAC;IAAG;IACrD,MAAM1D,sBAAsBiB,WAAWD,MAAME,KAAK,EAAE,aACjDW,GAAG,CAACb,MAAMsC,UAAU,EACpBJ,MAAM,CAACO,OACPE,KAAK,CAAC,CAACC;QACN,qEAAqE;QACrE,8DAA8D;QAC9DC,QAAQC,IAAI,CAAC,2DAA2DF;IAC1E;AACJ;AAEA;;;;;;;;;;CAUC,GACD,eAAeL,eACbtC,SAAsC,EACtCD,KAAyD;IAEzD,IAAI,CAACA,MAAMsC,UAAU,EAAE;IACvB,MAAMS,MAAMtE,gBAAgBuB,MAAMI,GAAG;IACrC,MAAMQ,MAAM5B,sBAAsBiB,WAAWD,MAAME,KAAK,EAAE,aACvDW,GAAG,CAACb,MAAMsC,UAAU,EACpBU,UAAU,CAAClE,yBAAyB,CAAC,EAAE,EACvC+B,GAAG,CAAC/B,yBAAyB,CAAC,EAAE;IACnC,IAAI,CAACiE,KAAK;QACR,MAAMnC,IACHqC,GAAG,CAAC;YAAEC,oBAAoBxE,WAAWgE,SAAS,CAAC;QAAG,GAAG;YAAES,OAAO;QAAK,GACnER,KAAK,CAAC,IAAMrB;QACf;IACF;IACA,IAAI;QACF,MAAMrB,UAAUc,cAAc,CAAC,OAAOC;gBAEHE;YADjC,MAAMA,WAAW,MAAMF,YAAYG,GAAG,CAACP;YACvC,MAAMwC,QAASlC,SAASE,MAAM,IAAGF,iBAAAA,SAASG,IAAI,uBAAbH,cAAiB,CAAC,QAAQ,GAAG;YAG9D,MAAMD,OAAOmC,yBAAAA,KAAO,CAACC,oBAAoBN,KAAK;YAC9C,IAAI,CAAC9B,QAAQqC,OAAOC,IAAI,CAACH,gBAAAA,QAAS,CAAC,GAAG5D,MAAM,IAAIhB,0BAA0B;gBACxEwC,YAAYiC,GAAG,CAACrC,KAAK;oBAAE4C,gBAAgB9E,WAAWgE,SAAS,CAAC;gBAAG,GAAG;oBAAES,OAAO;gBAAK;gBAChF;YACF;YACAnC,YAAYiC,GAAG,CACbrC,KACA;gBACEwC,OAAO;oBACL,CAACC,oBAAoBN,KAAK,EAAE;wBAC1B3C,KAAK2C;wBACLjB,QAAQ,AAAClC,CAAAA,OAAOqB,wBAAAA,KAAMa,MAAM,KAAK,CAAA,IAAK;oBACxC;gBACF;YACF,GACA;gBAAEqB,OAAO;YAAK;QAElB;IACF,EAAE,OAAOP,OAAO;QACdC,QAAQC,IAAI,CAAC,4DAA4DF;IAC3E;AACF;AAEA;;;;;;;CAOC,GACD,SAASS,oBAAoBN,GAAW;IACtC,OAAOU,OAAOC,IAAI,CAACX,KAAK,QAAQY,QAAQ,CAAC;AAC3C;AAEA;;;;;;CAMC,GACD,eAAenB,eACbzC,IAA2C,EAC3CC,KAAoF;IAEpF,MAAM4D,SAAS7D,KAAK8D,QAAQ;IAC5B,IAAI,CAACD,QAAQ;IACb,IAAI;YAQ8CnF;QAPhD,MAAMqF,UAAU,MAAMF,OAAOG,WAAW,CAAC;YACvC7D,OAAOF,MAAME,KAAK;YAClB8D,QAAQhE,MAAMb,UAAU,CAAC6E,MAAM;YAC/BC,MAAMlF,uBAAuBiB,MAAMb,UAAU;YAC7C+E,gBAAgBvF;YAChBe,MAAM;YACNC,MAAMK,MAAMU,KAAK;YACjByD,MAAM,CAAC,qCAAqC,GAAE1F,mBAAAA,gBAAgBuB,MAAMI,GAAG,aAAzB3B,mBAA8BuB,MAAMI,GAAG,EAAE;YACvFgE,OAAO;YACPC,QAAQ;QACV;QACA,IAAIP,QAAQQ,EAAE,KAAK,OAAO;YACxBzB,QAAQC,IAAI,CAAC,CAAC,mDAAmD,EAAEgB,QAAQlB,KAAK,EAAE;QACpF;IACF,EAAE,OAAOA,OAAO;QACdC,QAAQD,KAAK,CAAC,kDAAkDA;IAClE;AACF"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n CAMPAIGN_LINK_ROLLUP_MAX,\n campaignLinkKey,\n} from '@aglyn/shared-ui-email-campaigns/model'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport {\n judgeOutreachClick,\n type OutreachClickJudgement,\n} from '../engine/click-tracking'\nimport { readOutreachEngagement } from '../model/enrollment-engagement'\nimport { outreachClickHistoryRow, outreachHistoryEntryId } from '../model/enrollment-history'\nimport {\n OUTREACH_ENGAGEMENT_LINKS_MAX,\n OUTREACH_ENROLLMENT_HISTORY,\n OUTREACH_ENROLLMENT_HISTORY_MAX,\n OUTREACH_LINK_ROLLUP_PATH,\n type OutreachEnrollment,\n type OutreachEnrollmentEngagement,\n} from '../model/outreach.types'\nimport {\n outreachEnrollmentLink,\n outreachOrgCollection,\n readStoredOutreachEnrollment,\n} from '../storage/outreach-records'\nimport type { OutreachClickTarget } from './click-link'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * RECORDING A CLICK (AGL-3239).\n *\n * Every write here happens AFTER the recipient has been sent on their way.\n * A click is bookkeeping beside the act, in the same order `timeline.ts`\n * puts an email and its filing: the destination is in the token, so the\n * redirect needs nothing from Firestore to answer it, and a failure here\n * costs a number rather than a visit.\n *\n * Four places are written, because four different questions are asked of\n * them and no one document answers all of them:\n *\n * 1. **The enrollment** — \"did THIS person act?\", which the enrollments\n * table shows per row and a rep reads before calling someone: totals,\n * and the distinct destinations they followed.\n * 2. **The enrollment's history** (AGL-3332) — \"what exactly did they do,\n * and when?\", one row per visit: the destination, the step whose email\n * carried it, and whether a person or a scanner made it. Read only when\n * someone opens the person, so the table's listener never carries it.\n * 3. **The sequence** — \"is this sequence working?\", which is the report\n * card, and which cannot be derived from the enrollments because the\n * console lists them a page at a time.\n * 4. **The link rollup** — \"which of our links did they follow?\", one\n * document holding a bounded map, keyed and capped by the campaign\n * rollup's own rules so a link means the same thing in both reports.\n */\n\n/** What one recorded click turned out to be. */\nexport interface OutreachClickOutcome extends OutreachClickJudgement {\n /** Whether it was this person's first human click — the sequence's `uniqueClicks`. */\n first: boolean\n /** The enrollment it was recorded against, or `null` when there is none. */\n enrollment: OutreachEnrollment | null\n}\n\n/** When the step that carried the link was sent, or `null` when it cannot be found. */\nfunction sentAtMs(enrollment: OutreachEnrollment, stepIndex: number): number | null {\n const records = enrollment.stepRecords ?? []\n for (let index = records.length - 1; index >= 0; index -= 1) {\n const record = records[index]\n if (record?.stepIndex === stepIndex && record.kind === 'email') {\n return typeof record.atMs === 'number' && Number.isFinite(record.atMs) ? record.atMs : null\n }\n }\n return null\n}\n\n/**\n * Records one visit to a tracking link.\n *\n * The enrollment is read and written in a transaction, because two links\n * followed at once must not both read a `clicks` of nought and both write a\n * one — and because whether this is the person's FIRST click is decided from\n * the same read that writes it, which is what keeps `uniqueClicks` from\n * counting one person twice.\n *\n * An enrollment that no longer exists belongs to a person or a workspace\n * already erased. Nothing is recorded for them, anywhere — a deleted record\n * must not be reconstructed from a click on an old email — and the caller\n * has already answered the redirect either way.\n */\nexport async function recordOutreachClick(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n input: {\n target: OutreachClickTarget\n method: string\n userAgent: string | null | undefined\n },\n): Promise<OutreachClickOutcome> {\n const firestore = deps.firestore()\n const { orgId, enrollmentId, stepIndex, url } = input.target\n // A test link (AGL-3325) names no enrollment and counts on nothing —\n // held here too, so no caller can record one by reaching past the route.\n if (input.target.test) {\n return { human: false, machineReason: null, first: false, enrollment: null }\n }\n const nowMs = deps.now()\n const ref = outreachOrgCollection(firestore, orgId, 'enrollments').doc(enrollmentId)\n\n const outcome = await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const enrollment = readStoredOutreachEnrollment(\n enrollmentId,\n snapshot.exists ? snapshot.data() : undefined,\n )\n if (!enrollment) {\n return { human: false, machineReason: null, first: false, enrollment: null } as OutreachClickOutcome\n }\n const sent = sentAtMs(enrollment, stepIndex)\n const judgement = judgeOutreachClick({\n method: input.method,\n userAgent: input.userAgent,\n sinceSentMs: sent === null ? null : nowMs - sent,\n })\n const held = readOutreachEngagement(enrollment.engagement)\n const first = judgement.human && held.firstClickAtMs === null\n const key = campaignLinkKey(url)\n // A row for this visit, while the person's history has room for one.\n const logged = held.loggedClicks + held.loggedMachineClicks < OUTREACH_ENROLLMENT_HISTORY_MAX\n /*\n * The destinations known to have been followed. A click from before the\n * per-click history (AGL-3332) is known only by `lastClickUrl`, which the\n * next click replaces — so it joins the list first, or the person would\n * stop being found by the \"Link followed\" filter for the link they did\n * follow.\n */\n const known =\n held.lastClickUrl &&\n !held.links.includes(held.lastClickUrl) &&\n held.links.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...held.links, held.lastClickUrl]\n : held.links\n const engagement: OutreachEnrollmentEngagement = judgement.human\n ? {\n clicks: held.clicks + 1,\n firstClickAtMs: held.firstClickAtMs ?? nowMs,\n lastClickAtMs: nowMs,\n lastClickUrl: key ?? held.lastClickUrl,\n machineClicks: held.machineClicks,\n links:\n key && !known.includes(key) && known.length < OUTREACH_ENGAGEMENT_LINKS_MAX\n ? [...known, key]\n : known,\n loggedClicks: held.loggedClicks + (logged ? 1 : 0),\n loggedMachineClicks: held.loggedMachineClicks,\n }\n : {\n ...held,\n machineClicks: held.machineClicks + 1,\n loggedMachineClicks: held.loggedMachineClicks + (logged ? 1 : 0),\n }\n /*\n * `updatedAtMs` is deliberately NOT touched. It is what the enrollments\n * table sorts \"last activity\" by and what a member reads as \"something\n * happened to this enrollment\", and a scanner's fetch of a link is not\n * that. The engagement carries its own timestamps for what a click is.\n */\n // A person's click is what the \"Clicked\" filter asks about (AGL-3332); a scanner's is not.\n transaction.update(ref, judgement.human ? { engagement, clicked: true } : { engagement })\n /*\n * The row is written in the same transaction as the count it itemizes,\n * so `loggedClicks` is exactly the number of rows: the detail view says\n * how many clicks have none — before the history began — from the two\n * numbers alone, and never invents a row for one of them.\n */\n if (logged) {\n transaction.set(\n ref.collection(OUTREACH_ENROLLMENT_HISTORY).doc(outreachHistoryEntryId(nowMs)),\n outreachClickHistoryRow({\n atMs: nowMs,\n url: key,\n stepIndex,\n human: judgement.human,\n machineReason: judgement.machineReason,\n }),\n )\n }\n return { ...judgement, first, enrollment: { ...enrollment, engagement } } as OutreachClickOutcome\n })\n\n if (!outcome.enrollment) return outcome\n\n await Promise.all([\n bumpSequenceStats(firestore, outcome, {\n orgId,\n sequenceId: outcome.enrollment.sequenceId,\n nowMs,\n }),\n outcome.human ? bumpLinkRollup(firestore, { orgId, sequenceId: outcome.enrollment.sequenceId, url }) : null,\n outcome.first ? fileFirstClick(deps, { orgId, enrollment: outcome.enrollment, url, nowMs }) : null,\n ])\n return outcome\n}\n\n/**\n * The sequence's counters, incremented outside the enrollment's transaction.\n *\n * One sequence holds every enrollment's clicks, so a transaction over it\n * would serialise every recipient of a sequence against every other.\n * `FieldValue.increment` is a blind write with no read to contend on, which\n * is exactly what a counter wants.\n */\nasync function bumpSequenceStats(\n firestore: FirebaseFirestore.Firestore,\n outcome: OutreachClickOutcome,\n input: { orgId: string; sequenceId: string; nowMs: number },\n): Promise<void> {\n if (!input.sequenceId) return\n const patch: Record<string, unknown> = outcome.human\n ? {\n 'stats.clicks': FieldValue.increment(1),\n 'stats.lastClickAtMs': input.nowMs,\n ...(outcome.first ? { 'stats.uniqueClicks': FieldValue.increment(1) } : {}),\n }\n : { 'stats.machineClicks': FieldValue.increment(1) }\n await outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .update(patch)\n .catch((error: unknown) => {\n // A sequence deleted while its mail is still out there. The click is\n // already on the enrollment; the aggregate has nowhere to go.\n console.warn('[outreach] a click could not be counted on its sequence', error)\n })\n}\n\n/**\n * The per-destination rollup for one sequence.\n *\n * Keyed by `campaignLinkKey`, which drops the query string — a step body\n * goes through the merge resolver per recipient, so keying on the full URL\n * would mint one row per PERSON and turn the aggregate into the\n * per-recipient list it exists to summarise. Capped at\n * `CAMPAIGN_LINK_ROLLUP_MAX` for the same reason the campaign rollup is: it\n * is one document with a size ceiling. Past the cap a click is counted in\n * `overflowClicks` rather than dropped, so the table still reconciles.\n */\nasync function bumpLinkRollup(\n firestore: FirebaseFirestore.Firestore,\n input: { orgId: string; sequenceId: string; url: string },\n): Promise<void> {\n if (!input.sequenceId) return\n const key = campaignLinkKey(input.url)\n const ref = outreachOrgCollection(firestore, input.orgId, 'sequences')\n .doc(input.sequenceId)\n .collection(OUTREACH_LINK_ROLLUP_PATH[0])\n .doc(OUTREACH_LINK_ROLLUP_PATH[1])\n if (!key) {\n await ref\n .set({ unattributedClicks: FieldValue.increment(1) }, { merge: true })\n .catch(() => undefined)\n return\n }\n try {\n await firestore.runTransaction(async (transaction) => {\n const snapshot = await transaction.get(ref)\n const links = (snapshot.exists ? snapshot.data()?.['links'] : null) as\n | Record<string, { url?: string; clicks?: number }>\n | undefined\n const held = links?.[campaignRollupField(key)]\n if (!held && Object.keys(links ?? {}).length >= CAMPAIGN_LINK_ROLLUP_MAX) {\n transaction.set(ref, { overflowClicks: FieldValue.increment(1) }, { merge: true })\n return\n }\n transaction.set(\n ref,\n {\n links: {\n [campaignRollupField(key)]: {\n url: key,\n clicks: (Number(held?.clicks) || 0) + 1,\n },\n },\n },\n { merge: true },\n )\n })\n } catch (error) {\n console.warn('[outreach] a click could not be added to the link rollup', error)\n }\n}\n\n/**\n * The map key one destination is held under.\n *\n * A Firestore map key may not contain `/`, `.` or `~`, and a URL contains\n * all three, so the URL itself cannot be the key. It is base64url of the\n * key, with the readable form kept in the row's own `url` — which is what\n * the report renders, so nothing downstream ever decodes this.\n */\nfunction campaignRollupField(key: string): string {\n return Buffer.from(key, 'utf8').toString('base64url')\n}\n\n/**\n * A person's FIRST click on a sequence's link, on their record.\n *\n * Only the first. A note per click would bury the contact's timeline under\n * the same fact repeated, and the one a rep is reading for is that this\n * person went from ignoring the mail to acting on it.\n */\nasync function fileFirstClick(\n deps: Pick<OutreachRuntimeDeps, 'timeline'>,\n input: { orgId: string; enrollment: OutreachEnrollment; url: string; nowMs: number },\n): Promise<void> {\n const writer = deps.timeline()\n if (!writer) return\n try {\n const written = await writer.logActivity({\n orgId: input.orgId,\n hostId: input.enrollment.hostId,\n link: outreachEnrollmentLink(input.enrollment),\n sourcePluginId: OUTREACH_PLUGIN_ID,\n kind: 'note',\n atMs: input.nowMs,\n body: `Followed a link in a sequence email: ${campaignLinkKey(input.url) ?? input.url}`,\n byUid: '',\n byName: null,\n })\n if (written.ok === false) {\n console.warn(`[outreach] the record system did not file a click: ${written.error}`)\n }\n } catch (error) {\n console.error('[outreach] filing a click on the record failed', error)\n }\n}\n"],"names":["CAMPAIGN_LINK_ROLLUP_MAX","campaignLinkKey","FieldValue","OUTREACH_PLUGIN_ID","judgeOutreachClick","readOutreachEngagement","outreachClickHistoryRow","outreachHistoryEntryId","OUTREACH_ENGAGEMENT_LINKS_MAX","OUTREACH_ENROLLMENT_HISTORY","OUTREACH_ENROLLMENT_HISTORY_MAX","OUTREACH_LINK_ROLLUP_PATH","outreachEnrollmentLink","outreachOrgCollection","readStoredOutreachEnrollment","sentAtMs","enrollment","stepIndex","records","stepRecords","index","length","record","kind","atMs","Number","isFinite","recordOutreachClick","deps","input","firestore","orgId","enrollmentId","url","target","test","human","machineReason","first","nowMs","now","ref","doc","outcome","runTransaction","transaction","held","snapshot","get","exists","data","undefined","sent","judgement","method","userAgent","sinceSentMs","engagement","firstClickAtMs","key","logged","loggedClicks","loggedMachineClicks","known","lastClickUrl","links","includes","clicks","lastClickAtMs","machineClicks","update","clicked","set","collection","Promise","all","bumpSequenceStats","sequenceId","bumpLinkRollup","fileFirstClick","patch","increment","catch","error","console","warn","unattributedClicks","merge","campaignRollupField","Object","keys","overflowClicks","Buffer","from","toString","writer","timeline","written","logActivity","hostId","link","sourcePluginId","body","byUid","byName","ok"],"mappings":"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"}
@@ -22,6 +22,10 @@ export declare function recordOutreachGatewayBlock(firestore: Firestore, input:
22
22
  orgId: string;
23
23
  email: string;
24
24
  remoteMta: string | null;
25
+ /** The domain the mailbox sends from, whose ledger the block is counted on (AGL-3328). */
26
+ sendingDomain: string | null;
27
+ /** The bounce's diagnostic, kept scrubbed of addresses for the ledger. */
28
+ diagnostic?: string | null;
25
29
  resolveMx: OutreachResolveMx;
26
30
  nowMs: number;
27
31
  }): Promise<void>;
@@ -40,6 +44,8 @@ export declare function outreachDeliveredStepCount(enrollment: Pick<OutreachEnro
40
44
  export declare function creditOutreachGatewayDeliveries(firestore: Firestore, input: {
41
45
  orgId: string;
42
46
  enrollments: readonly OutreachEnrollment[];
47
+ /** The domain the mailbox sends from, whose ledger the deliveries are credited to (AGL-3328). */
48
+ sendingDomain: string | null;
43
49
  resolveMx: OutreachResolveMx;
44
50
  nowMs: number;
45
51
  }): Promise<number>;