@aglyn/plugins-outreach 1.0.0-beta.200 → 1.0.0-beta.202
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +14 -14
- package/src/lib/components/enrollment-actions.d.ts +33 -0
- package/src/lib/components/enrollment-actions.js +174 -0
- package/src/lib/components/enrollment-actions.js.map +1 -0
- package/src/lib/components/enrollment-detail.d.ts +21 -0
- package/src/lib/components/enrollment-detail.js +870 -0
- package/src/lib/components/enrollment-detail.js.map +1 -0
- package/src/lib/components/enrollment-filter-params.d.ts +58 -0
- package/src/lib/components/enrollment-filter-params.js +98 -0
- package/src/lib/components/enrollment-filter-params.js.map +1 -0
- package/src/lib/components/enrollments-table.d.ts +21 -0
- package/src/lib/components/enrollments-table.js +249 -210
- package/src/lib/components/enrollments-table.js.map +1 -1
- package/src/lib/components/outreach-console-page.js +10 -0
- package/src/lib/components/outreach-console-page.js.map +1 -1
- package/src/lib/components/sequence-detail.d.ts +2 -0
- package/src/lib/components/sequence-detail.js +70 -8
- package/src/lib/components/sequence-detail.js.map +1 -1
- package/src/lib/components/sequence-report-card.d.ts +17 -0
- package/src/lib/components/sequence-report-card.js +63 -15
- package/src/lib/components/sequence-report-card.js.map +1 -1
- package/src/lib/components/sequences-section.d.ts +4 -1
- package/src/lib/components/sequences-section.js +12 -1
- package/src/lib/components/sequences-section.js.map +1 -1
- package/src/lib/components/use-outreach-api.d.ts +3 -1
- package/src/lib/components/use-outreach-api.js +6 -0
- package/src/lib/components/use-outreach-api.js.map +1 -1
- package/src/lib/components/use-outreach-enrollment.d.ts +18 -0
- package/src/lib/components/use-outreach-enrollment.js +133 -0
- package/src/lib/components/use-outreach-enrollment.js.map +1 -0
- package/src/lib/constants/api-routes.d.ts +2 -0
- package/src/lib/constants/api-routes.js +1 -0
- package/src/lib/constants/api-routes.js.map +1 -1
- package/src/lib/engine/gates.d.ts +4 -3
- package/src/lib/engine/gates.js.map +1 -1
- package/src/lib/engine/gateway-block.d.ts +4 -8
- package/src/lib/engine/gateway-block.js +6 -26
- package/src/lib/engine/gateway-block.js.map +1 -1
- package/src/lib/engine/mail-gateway.d.ts +18 -73
- package/src/lib/engine/mail-gateway.js +32 -206
- package/src/lib/engine/mail-gateway.js.map +1 -1
- package/src/lib/model/enrollment-engagement.d.ts +26 -2
- package/src/lib/model/enrollment-engagement.js +40 -3
- package/src/lib/model/enrollment-engagement.js.map +1 -1
- package/src/lib/model/enrollment-history.d.ts +40 -0
- package/src/lib/model/enrollment-history.js +95 -0
- package/src/lib/model/enrollment-history.js.map +1 -0
- package/src/lib/model/enrollment-list-query.d.ts +6 -0
- package/src/lib/model/enrollment-list-query.js +35 -1
- package/src/lib/model/enrollment-list-query.js.map +1 -1
- package/src/lib/model/enrollment-timeline.d.ts +78 -0
- package/src/lib/model/enrollment-timeline.js +430 -0
- package/src/lib/model/enrollment-timeline.js.map +1 -0
- package/src/lib/model/outreach-api.d.ts +24 -0
- package/src/lib/model/outreach-api.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +78 -29
- package/src/lib/model/outreach.types.js +33 -8
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +26 -3
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/enrollment-routes.d.ts +10 -0
- package/src/lib/routes/enrollment-routes.js +82 -1
- package/src/lib/routes/enrollment-routes.js.map +1 -1
- package/src/lib/routes/register-routes.js +14 -4
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +3 -1
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/runtime/click-events.d.ts +10 -5
- package/src/lib/runtime/click-events.js +45 -6
- package/src/lib/runtime/click-events.js.map +1 -1
- package/src/lib/runtime/gateway-ledger.d.ts +6 -0
- package/src/lib/runtime/gateway-ledger.js +9 -3
- package/src/lib/runtime/gateway-ledger.js.map +1 -1
- package/src/lib/runtime/host-suspension.d.ts +30 -0
- package/src/lib/runtime/host-suspension.js +35 -0
- package/src/lib/runtime/host-suspension.js.map +1 -0
- package/src/lib/runtime/person-erasure.d.ts +4 -1
- package/src/lib/runtime/person-erasure.js +14 -3
- package/src/lib/runtime/person-erasure.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.js +34 -3
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +27 -1
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +46 -6
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +4 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
- package/src/lib/storage/domain-intel-store.d.ts +61 -43
- package/src/lib/storage/domain-intel-store.js +138 -226
- 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 {
|
|
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 {
|
|
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),
|
|
89
|
-
|
|
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) —
|
|
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) —
|
|
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
|
-
*
|
|
31
|
-
* them and no one document answers all
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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:
|
|
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
|
-
*/
|
|
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":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-events.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport {\n 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>;
|