@aglyn/plugins-outreach 1.0.0-beta.162 → 1.0.0-beta.164
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 +13 -13
- package/src/lib/components/sequence-editor.js +24 -3
- package/src/lib/components/sequence-editor.js.map +1 -1
- package/src/lib/declarations.console-server.js +27 -3
- package/src/lib/declarations.console-server.js.map +1 -1
- package/src/lib/engine/enrollment-state.d.ts +1 -1
- package/src/lib/engine/enrollment-state.js +4 -0
- package/src/lib/engine/enrollment-state.js.map +1 -1
- package/src/lib/model/outreach.types.d.ts +15 -0
- package/src/lib/model/outreach.types.js.map +1 -1
- package/src/lib/model/sequence-draft.d.ts +8 -2
- package/src/lib/model/sequence-draft.js +6 -3
- package/src/lib/model/sequence-draft.js.map +1 -1
- package/src/lib/model/stored-records.js +6 -1
- package/src/lib/model/stored-records.js.map +1 -1
- package/src/lib/routes/enroll-routes.js +39 -1
- package/src/lib/routes/enroll-routes.js.map +1 -1
- package/src/lib/routes/platform-deps.d.ts +2 -1
- package/src/lib/routes/platform-deps.js +3 -2
- package/src/lib/routes/platform-deps.js.map +1 -1
- package/src/lib/routes/register-routes.js +8 -0
- package/src/lib/routes/register-routes.js.map +1 -1
- package/src/lib/routes/route-deps.d.ts +6 -1
- package/src/lib/routes/route-deps.js.map +1 -1
- package/src/lib/routes/sequence-routes.js +25 -2
- package/src/lib/routes/sequence-routes.js.map +1 -1
- package/src/lib/runtime/campaign-credit.d.ts +81 -0
- package/src/lib/runtime/campaign-credit.js +133 -0
- package/src/lib/runtime/campaign-credit.js.map +1 -0
- package/src/lib/runtime/campaign-detach.d.ts +21 -0
- package/src/lib/runtime/campaign-detach.js +85 -0
- package/src/lib/runtime/campaign-detach.js.map +1 -0
- package/src/lib/runtime/click-route.d.ts +1 -1
- package/src/lib/runtime/click-route.js +13 -1
- package/src/lib/runtime/click-route.js.map +1 -1
- package/src/lib/runtime/platform-runtime-deps.d.ts +8 -1
- package/src/lib/runtime/platform-runtime-deps.js +45 -0
- package/src/lib/runtime/platform-runtime-deps.js.map +1 -1
- package/src/lib/runtime/runtime-deps.d.ts +56 -0
- package/src/lib/runtime/runtime-deps.js.map +1 -1
- package/src/lib/runtime/send-job.js +17 -2
- package/src/lib/runtime/send-job.js.map +1 -1
- package/src/lib/runtime/sync-job.js +7 -0
- package/src/lib/runtime/sync-job.js.map +1 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { OutreachEnrollment } from '../model/outreach.types';
|
|
18
|
+
import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
19
|
+
/**
|
|
20
|
+
* WHAT A SEQUENCE PRODUCED, CREDITED TO ITS CAMPAIGNS (AGL-3254).
|
|
21
|
+
*
|
|
22
|
+
* A sequence joins a campaign the way a form does, and every person
|
|
23
|
+
* enrolled carries the sequence's campaigns as they stood at enrollment.
|
|
24
|
+
* From then on each outcome of the enrollment is credited to those
|
|
25
|
+
* campaigns through the platform's campaign attribution — the runtime's
|
|
26
|
+
* `campaignCredit` seam — and never by writing the marketing plugin's
|
|
27
|
+
* documents or importing it:
|
|
28
|
+
*
|
|
29
|
+
* - `enrolled`, by the enroll route, when the enrollment is created;
|
|
30
|
+
* - `sent`, here, when the enrollment's FIRST email leaves — which is also
|
|
31
|
+
* when the person's own record gains the sequence's campaign as "where
|
|
32
|
+
* this came from", because a first email is the touch;
|
|
33
|
+
* - `replied`, here, when the reply stops the sequence;
|
|
34
|
+
* - `converted`, here, when a lead in a sequence becomes a contact;
|
|
35
|
+
* - `meetings`, by the booking door, from the touch the click route stamps.
|
|
36
|
+
*
|
|
37
|
+
* Every credit happens once per enrollment because each is tied to a state
|
|
38
|
+
* change that happens once: there is one first email, a reply moves the
|
|
39
|
+
* status to `replied` and nothing sends after, a lead converts once. An
|
|
40
|
+
* enrollment naming no campaign credits nothing, silently — a sequence
|
|
41
|
+
* outside every campaign is the ordinary case, not an error.
|
|
42
|
+
*/
|
|
43
|
+
/** The campaigns an enrollment credits: what it carries, cleaned. */
|
|
44
|
+
export declare function outreachEnrollmentCampaignIds(enrollment: Pick<OutreachEnrollment, 'campaignIds'>): string[];
|
|
45
|
+
/** Whether the email about to be recorded is the enrollment's first. */
|
|
46
|
+
export declare function isOutreachFirstEmail(enrollment: Pick<OutreachEnrollment, 'stepRecords'>): boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The first email of an enrollment left: `sent` on every campaign, and the
|
|
49
|
+
* person's record credited to the first of them. One campaign on the
|
|
50
|
+
* record, because the record's "where this came from" names one touch; the
|
|
51
|
+
* sequence's first campaign is the one the rep listed first.
|
|
52
|
+
*/
|
|
53
|
+
export declare function creditOutreachFirstSend(deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>, input: {
|
|
54
|
+
enrollment: OutreachEnrollment;
|
|
55
|
+
atMs: number;
|
|
56
|
+
}): Promise<void>;
|
|
57
|
+
/** A reply stopped the sequence: `replied` on every campaign. */
|
|
58
|
+
export declare function creditOutreachReply(deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>, input: {
|
|
59
|
+
enrollment: OutreachEnrollment;
|
|
60
|
+
atMs: number;
|
|
61
|
+
}): Promise<void>;
|
|
62
|
+
/**
|
|
63
|
+
* A person clicked a link in a sequence email: the sequence's campaign
|
|
64
|
+
* becomes their last campaign touch on the site, so what they go on to do
|
|
65
|
+
* there — book, submit a form — is credited to it by the platform's own
|
|
66
|
+
* doors. The first campaign, for the record-side reason above.
|
|
67
|
+
*/
|
|
68
|
+
export declare function recordOutreachSequenceTouch(deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>, input: {
|
|
69
|
+
enrollment: OutreachEnrollment;
|
|
70
|
+
atMs: number;
|
|
71
|
+
}): Promise<void>;
|
|
72
|
+
/**
|
|
73
|
+
* A lead in a sequence became a contact: `converted` on every campaign of
|
|
74
|
+
* every enrollment made on the lead. Run from the lead-conversion seam,
|
|
75
|
+
* which fires once per conversion. Answers how many enrollments credited.
|
|
76
|
+
*/
|
|
77
|
+
export declare function creditOutreachLeadConversion(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'campaignCredit'>, input: {
|
|
78
|
+
orgId: string;
|
|
79
|
+
hostId: string;
|
|
80
|
+
leadId: string;
|
|
81
|
+
}): Promise<number>;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
2
|
+
/**
|
|
3
|
+
* @license
|
|
4
|
+
* Copyright 2026 Aglyn LLC
|
|
5
|
+
*
|
|
6
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
7
|
+
* you may not use this file except in compliance with the License.
|
|
8
|
+
* You may obtain a copy of the License at
|
|
9
|
+
*
|
|
10
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
11
|
+
*
|
|
12
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
13
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
14
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
15
|
+
* See the License for the specific language governing permissions and
|
|
16
|
+
* limitations under the License.
|
|
17
|
+
*/ import { normalizeCampaignIds } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
18
|
+
import { outreachOrgCollection, readStoredOutreachEnrollment } from "../storage/outreach-records.js";
|
|
19
|
+
/**
|
|
20
|
+
* WHAT A SEQUENCE PRODUCED, CREDITED TO ITS CAMPAIGNS (AGL-3254).
|
|
21
|
+
*
|
|
22
|
+
* A sequence joins a campaign the way a form does, and every person
|
|
23
|
+
* enrolled carries the sequence's campaigns as they stood at enrollment.
|
|
24
|
+
* From then on each outcome of the enrollment is credited to those
|
|
25
|
+
* campaigns through the platform's campaign attribution — the runtime's
|
|
26
|
+
* `campaignCredit` seam — and never by writing the marketing plugin's
|
|
27
|
+
* documents or importing it:
|
|
28
|
+
*
|
|
29
|
+
* - `enrolled`, by the enroll route, when the enrollment is created;
|
|
30
|
+
* - `sent`, here, when the enrollment's FIRST email leaves — which is also
|
|
31
|
+
* when the person's own record gains the sequence's campaign as "where
|
|
32
|
+
* this came from", because a first email is the touch;
|
|
33
|
+
* - `replied`, here, when the reply stops the sequence;
|
|
34
|
+
* - `converted`, here, when a lead in a sequence becomes a contact;
|
|
35
|
+
* - `meetings`, by the booking door, from the touch the click route stamps.
|
|
36
|
+
*
|
|
37
|
+
* Every credit happens once per enrollment because each is tied to a state
|
|
38
|
+
* change that happens once: there is one first email, a reply moves the
|
|
39
|
+
* status to `replied` and nothing sends after, a lead converts once. An
|
|
40
|
+
* enrollment naming no campaign credits nothing, silently — a sequence
|
|
41
|
+
* outside every campaign is the ordinary case, not an error.
|
|
42
|
+
*/ /** The campaigns an enrollment credits: what it carries, cleaned. */ export function outreachEnrollmentCampaignIds(enrollment) {
|
|
43
|
+
return normalizeCampaignIds(enrollment.campaignIds);
|
|
44
|
+
}
|
|
45
|
+
/** Whether the email about to be recorded is the enrollment's first. */ export function isOutreachFirstEmail(enrollment) {
|
|
46
|
+
var _enrollment_stepRecords;
|
|
47
|
+
return !((_enrollment_stepRecords = enrollment.stepRecords) != null ? _enrollment_stepRecords : []).some((record)=>record.kind === 'email');
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The first email of an enrollment left: `sent` on every campaign, and the
|
|
51
|
+
* person's record credited to the first of them. One campaign on the
|
|
52
|
+
* record, because the record's "where this came from" names one touch; the
|
|
53
|
+
* sequence's first campaign is the one the rep listed first.
|
|
54
|
+
*/ export async function creditOutreachFirstSend(deps, input) {
|
|
55
|
+
const { enrollment, atMs } = input;
|
|
56
|
+
const campaignIds = outreachEnrollmentCampaignIds(enrollment);
|
|
57
|
+
if (!campaignIds.length || !isOutreachFirstEmail(enrollment)) return;
|
|
58
|
+
await deps.campaignCredit.credit({
|
|
59
|
+
hostId: enrollment.hostId,
|
|
60
|
+
campaignIds,
|
|
61
|
+
outcome: 'sent',
|
|
62
|
+
atMs
|
|
63
|
+
});
|
|
64
|
+
const record = enrollment.target === 'lead' && enrollment.leadId ? {
|
|
65
|
+
kind: 'lead',
|
|
66
|
+
refId: enrollment.leadId
|
|
67
|
+
} : enrollment.contactId ? {
|
|
68
|
+
kind: 'contact',
|
|
69
|
+
refId: enrollment.contactId
|
|
70
|
+
} : null;
|
|
71
|
+
if (!record) return;
|
|
72
|
+
await deps.campaignCredit.attributeRecord(_extends({
|
|
73
|
+
hostId: enrollment.hostId
|
|
74
|
+
}, record, {
|
|
75
|
+
campaignId: campaignIds[0],
|
|
76
|
+
sequenceId: enrollment.sequenceId,
|
|
77
|
+
enrollmentId: enrollment.id,
|
|
78
|
+
atMs
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
/** A reply stopped the sequence: `replied` on every campaign. */ export async function creditOutreachReply(deps, input) {
|
|
82
|
+
const campaignIds = outreachEnrollmentCampaignIds(input.enrollment);
|
|
83
|
+
if (!campaignIds.length) return;
|
|
84
|
+
await deps.campaignCredit.credit({
|
|
85
|
+
hostId: input.enrollment.hostId,
|
|
86
|
+
campaignIds,
|
|
87
|
+
outcome: 'replied',
|
|
88
|
+
atMs: input.atMs
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A person clicked a link in a sequence email: the sequence's campaign
|
|
93
|
+
* becomes their last campaign touch on the site, so what they go on to do
|
|
94
|
+
* there — book, submit a form — is credited to it by the platform's own
|
|
95
|
+
* doors. The first campaign, for the record-side reason above.
|
|
96
|
+
*/ export async function recordOutreachSequenceTouch(deps, input) {
|
|
97
|
+
const { enrollment } = input;
|
|
98
|
+
const campaignIds = outreachEnrollmentCampaignIds(enrollment);
|
|
99
|
+
if (!campaignIds.length || !enrollment.email) return;
|
|
100
|
+
await deps.campaignCredit.recordTouch({
|
|
101
|
+
hostId: enrollment.hostId,
|
|
102
|
+
email: enrollment.email,
|
|
103
|
+
campaignId: campaignIds[0],
|
|
104
|
+
sequenceId: enrollment.sequenceId,
|
|
105
|
+
enrollmentId: enrollment.id,
|
|
106
|
+
atMs: input.atMs
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A lead in a sequence became a contact: `converted` on every campaign of
|
|
111
|
+
* every enrollment made on the lead. Run from the lead-conversion seam,
|
|
112
|
+
* which fires once per conversion. Answers how many enrollments credited.
|
|
113
|
+
*/ export async function creditOutreachLeadConversion(deps, input) {
|
|
114
|
+
const rows = await outreachOrgCollection(deps.firestore(), input.orgId, 'enrollments').where('leadId', '==', input.leadId).get();
|
|
115
|
+
const atMs = deps.now();
|
|
116
|
+
let credited = 0;
|
|
117
|
+
for (const row of rows.docs){
|
|
118
|
+
const enrollment = readStoredOutreachEnrollment(row.id, row.data());
|
|
119
|
+
if (!enrollment || enrollment.hostId !== input.hostId) continue;
|
|
120
|
+
const campaignIds = outreachEnrollmentCampaignIds(enrollment);
|
|
121
|
+
if (!campaignIds.length) continue;
|
|
122
|
+
await deps.campaignCredit.credit({
|
|
123
|
+
hostId: input.hostId,
|
|
124
|
+
campaignIds,
|
|
125
|
+
outcome: 'converted',
|
|
126
|
+
atMs
|
|
127
|
+
});
|
|
128
|
+
credited += 1;
|
|
129
|
+
}
|
|
130
|
+
return credited;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
//# sourceMappingURL=campaign-credit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-credit.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { normalizeCampaignIds } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type { OutreachEnrollment } from '../model/outreach.types'\nimport { outreachOrgCollection, readStoredOutreachEnrollment } from '../storage/outreach-records'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n\n/**\n * WHAT A SEQUENCE PRODUCED, CREDITED TO ITS CAMPAIGNS (AGL-3254).\n *\n * A sequence joins a campaign the way a form does, and every person\n * enrolled carries the sequence's campaigns as they stood at enrollment.\n * From then on each outcome of the enrollment is credited to those\n * campaigns through the platform's campaign attribution — the runtime's\n * `campaignCredit` seam — and never by writing the marketing plugin's\n * documents or importing it:\n *\n * - `enrolled`, by the enroll route, when the enrollment is created;\n * - `sent`, here, when the enrollment's FIRST email leaves — which is also\n * when the person's own record gains the sequence's campaign as \"where\n * this came from\", because a first email is the touch;\n * - `replied`, here, when the reply stops the sequence;\n * - `converted`, here, when a lead in a sequence becomes a contact;\n * - `meetings`, by the booking door, from the touch the click route stamps.\n *\n * Every credit happens once per enrollment because each is tied to a state\n * change that happens once: there is one first email, a reply moves the\n * status to `replied` and nothing sends after, a lead converts once. An\n * enrollment naming no campaign credits nothing, silently — a sequence\n * outside every campaign is the ordinary case, not an error.\n */\n\n/** The campaigns an enrollment credits: what it carries, cleaned. */\nexport function outreachEnrollmentCampaignIds(\n enrollment: Pick<OutreachEnrollment, 'campaignIds'>,\n): string[] {\n return normalizeCampaignIds(enrollment.campaignIds)\n}\n\n/** Whether the email about to be recorded is the enrollment's first. */\nexport function isOutreachFirstEmail(enrollment: Pick<OutreachEnrollment, 'stepRecords'>): boolean {\n return !(enrollment.stepRecords ?? []).some((record) => record.kind === 'email')\n}\n\n/**\n * The first email of an enrollment left: `sent` on every campaign, and the\n * person's record credited to the first of them. One campaign on the\n * record, because the record's \"where this came from\" names one touch; the\n * sequence's first campaign is the one the rep listed first.\n */\nexport async function creditOutreachFirstSend(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const { enrollment, atMs } = input\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length || !isOutreachFirstEmail(enrollment)) return\n await deps.campaignCredit.credit({ hostId: enrollment.hostId, campaignIds, outcome: 'sent', atMs })\n const record =\n enrollment.target === 'lead' && enrollment.leadId\n ? { kind: 'lead' as const, refId: enrollment.leadId }\n : enrollment.contactId\n ? { kind: 'contact' as const, refId: enrollment.contactId }\n : null\n if (!record) return\n await deps.campaignCredit.attributeRecord({\n hostId: enrollment.hostId,\n ...record,\n campaignId: campaignIds[0],\n sequenceId: enrollment.sequenceId,\n enrollmentId: enrollment.id,\n atMs,\n })\n}\n\n/** A reply stopped the sequence: `replied` on every campaign. */\nexport async function creditOutreachReply(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const campaignIds = outreachEnrollmentCampaignIds(input.enrollment)\n if (!campaignIds.length) return\n await deps.campaignCredit.credit({\n hostId: input.enrollment.hostId,\n campaignIds,\n outcome: 'replied',\n atMs: input.atMs,\n })\n}\n\n/**\n * A person clicked a link in a sequence email: the sequence's campaign\n * becomes their last campaign touch on the site, so what they go on to do\n * there — book, submit a form — is credited to it by the platform's own\n * doors. The first campaign, for the record-side reason above.\n */\nexport async function recordOutreachSequenceTouch(\n deps: Pick<OutreachRuntimeDeps, 'campaignCredit'>,\n input: { enrollment: OutreachEnrollment; atMs: number },\n): Promise<void> {\n const { enrollment } = input\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length || !enrollment.email) return\n await deps.campaignCredit.recordTouch({\n hostId: enrollment.hostId,\n email: enrollment.email,\n campaignId: campaignIds[0],\n sequenceId: enrollment.sequenceId,\n enrollmentId: enrollment.id,\n atMs: input.atMs,\n })\n}\n\n/**\n * A lead in a sequence became a contact: `converted` on every campaign of\n * every enrollment made on the lead. Run from the lead-conversion seam,\n * which fires once per conversion. Answers how many enrollments credited.\n */\nexport async function creditOutreachLeadConversion(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'campaignCredit'>,\n input: { orgId: string; hostId: string; leadId: string },\n): Promise<number> {\n const rows = await outreachOrgCollection(deps.firestore(), input.orgId, 'enrollments')\n .where('leadId', '==', input.leadId)\n .get()\n const atMs = deps.now()\n let credited = 0\n for (const row of rows.docs) {\n const enrollment = readStoredOutreachEnrollment(row.id, row.data())\n if (!enrollment || enrollment.hostId !== input.hostId) continue\n const campaignIds = outreachEnrollmentCampaignIds(enrollment)\n if (!campaignIds.length) continue\n await deps.campaignCredit.credit({ hostId: input.hostId, campaignIds, outcome: 'converted', atMs })\n credited += 1\n }\n return credited\n}\n"],"names":["normalizeCampaignIds","outreachOrgCollection","readStoredOutreachEnrollment","outreachEnrollmentCampaignIds","enrollment","campaignIds","isOutreachFirstEmail","stepRecords","some","record","kind","creditOutreachFirstSend","deps","input","atMs","length","campaignCredit","credit","hostId","outcome","target","leadId","refId","contactId","attributeRecord","campaignId","sequenceId","enrollmentId","id","creditOutreachReply","recordOutreachSequenceTouch","email","recordTouch","creditOutreachLeadConversion","rows","firestore","orgId","where","get","now","credited","row","docs","data"],"mappings":"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"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginMembershipDetacher } from '@aglyn/aglyn/plugin-manager/plugin-membership-detach';
|
|
18
|
+
/** The detacher, on the Firestore it is handed. */
|
|
19
|
+
export declare function createOutreachCampaignDetacher(deps: {
|
|
20
|
+
firestore(): FirebaseFirestore.Firestore;
|
|
21
|
+
}): PluginMembershipDetacher;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2026 Aglyn LLC
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at
|
|
8
|
+
*
|
|
9
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
* See the License for the specific language governing permissions and
|
|
15
|
+
* limitations under the License.
|
|
16
|
+
*/ import { CAMPAIGN_MEMBERSHIP_FIELD } from "@aglyn/aglyn/app-utils/campaign-membership";
|
|
17
|
+
import { FieldValue } from "firebase-admin/firestore";
|
|
18
|
+
import { outreachOrgCollection } from "../storage/outreach-records.js";
|
|
19
|
+
/**
|
|
20
|
+
* OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).
|
|
21
|
+
*
|
|
22
|
+
* A sequence and its enrollments name the campaigns they are in from their
|
|
23
|
+
* own documents, under the organization, in collections the core does not
|
|
24
|
+
* name — so the marketing plugin's deletion cannot reach them and asks
|
|
25
|
+
* every plugin's membership detacher instead. This one clears the campaign
|
|
26
|
+
* id out of every sequence and every enrollment of the org still carrying
|
|
27
|
+
* it, with the same walk the owner makes over its forms: `array-contains`
|
|
28
|
+
* on the automatic single-field index, `arrayRemove` of the one id, in
|
|
29
|
+
* pages under Firestore's batch limit, bounded per request and reporting
|
|
30
|
+
* `remaining` so the deletion holds the container for a second run.
|
|
31
|
+
*
|
|
32
|
+
* Only the campaign membership field is Outreach's to clear: a request
|
|
33
|
+
* about any other container's field answers nothing detached, because the
|
|
34
|
+
* sequences carry no such field. The records themselves stay, and so do
|
|
35
|
+
* the campaign's credits: what a sequence produced for a campaign that is
|
|
36
|
+
* gone is in a report document nobody reads any more, which is the
|
|
37
|
+
* conversions rollup's own fate.
|
|
38
|
+
*/ /** Members detached in one write, under the 500-operation batch limit. */ const DETACH_BATCH = 400;
|
|
39
|
+
/** How many pages one request walks per collection. */ const DETACH_PASSES = 25;
|
|
40
|
+
async function detachFrom(collection, campaignId) {
|
|
41
|
+
const firestore = collection.firestore;
|
|
42
|
+
let detached = 0;
|
|
43
|
+
for(let pass = 0; pass < DETACH_PASSES; pass += 1){
|
|
44
|
+
const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get();
|
|
45
|
+
if (page.empty) return {
|
|
46
|
+
detached,
|
|
47
|
+
remaining: false
|
|
48
|
+
};
|
|
49
|
+
const batch = firestore.batch();
|
|
50
|
+
for (const member of page.docs){
|
|
51
|
+
batch.update(member.ref, {
|
|
52
|
+
[CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId)
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
await batch.commit();
|
|
56
|
+
detached += page.size;
|
|
57
|
+
if (page.size < DETACH_BATCH) return {
|
|
58
|
+
detached,
|
|
59
|
+
remaining: false
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
detached,
|
|
64
|
+
remaining: true
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** The detacher, on the Firestore it is handed. */ export function createOutreachCampaignDetacher(deps) {
|
|
68
|
+
return async ({ orgId, field, id })=>{
|
|
69
|
+
// A site with no organization holds no sequences, and a container held
|
|
70
|
+
// in any other field is not one a sequence joins: nothing to detach.
|
|
71
|
+
if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return {
|
|
72
|
+
detached: 0,
|
|
73
|
+
remaining: false
|
|
74
|
+
};
|
|
75
|
+
const firestore = deps.firestore();
|
|
76
|
+
const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id);
|
|
77
|
+
const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id);
|
|
78
|
+
return {
|
|
79
|
+
detached: sequences.detached + enrollments.detached,
|
|
80
|
+
remaining: sequences.remaining || enrollments.remaining
|
|
81
|
+
};
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
//# sourceMappingURL=campaign-detach.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/campaign-detach.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport { CAMPAIGN_MEMBERSHIP_FIELD } from '@aglyn/aglyn/app-utils/campaign-membership'\nimport type {\n PluginMembershipDetacher,\n PluginMembershipDetachReport,\n} from '@aglyn/aglyn/plugin-manager/plugin-membership-detach'\nimport { FieldValue } from 'firebase-admin/firestore'\nimport { outreachOrgCollection } from '../storage/outreach-records'\n\n/**\n * OUTREACH'S SHARE OF A CAMPAIGN DELETION (AGL-3254).\n *\n * A sequence and its enrollments name the campaigns they are in from their\n * own documents, under the organization, in collections the core does not\n * name — so the marketing plugin's deletion cannot reach them and asks\n * every plugin's membership detacher instead. This one clears the campaign\n * id out of every sequence and every enrollment of the org still carrying\n * it, with the same walk the owner makes over its forms: `array-contains`\n * on the automatic single-field index, `arrayRemove` of the one id, in\n * pages under Firestore's batch limit, bounded per request and reporting\n * `remaining` so the deletion holds the container for a second run.\n *\n * Only the campaign membership field is Outreach's to clear: a request\n * about any other container's field answers nothing detached, because the\n * sequences carry no such field. The records themselves stay, and so do\n * the campaign's credits: what a sequence produced for a campaign that is\n * gone is in a report document nobody reads any more, which is the\n * conversions rollup's own fate.\n */\n\n/** Members detached in one write, under the 500-operation batch limit. */\nconst DETACH_BATCH = 400\n\n/** How many pages one request walks per collection. */\nconst DETACH_PASSES = 25\n\nasync function detachFrom(\n collection: FirebaseFirestore.CollectionReference,\n campaignId: string,\n): Promise<PluginMembershipDetachReport> {\n const firestore = collection.firestore\n let detached = 0\n for (let pass = 0; pass < DETACH_PASSES; pass += 1) {\n const page = await collection.where(CAMPAIGN_MEMBERSHIP_FIELD, 'array-contains', campaignId).limit(DETACH_BATCH).get()\n if (page.empty) return { detached, remaining: false }\n const batch = firestore.batch()\n for (const member of page.docs) {\n batch.update(member.ref, { [CAMPAIGN_MEMBERSHIP_FIELD]: FieldValue.arrayRemove(campaignId) })\n }\n await batch.commit()\n detached += page.size\n if (page.size < DETACH_BATCH) return { detached, remaining: false }\n }\n return { detached, remaining: true }\n}\n\n/** The detacher, on the Firestore it is handed. */\nexport function createOutreachCampaignDetacher(deps: {\n firestore(): FirebaseFirestore.Firestore\n}): PluginMembershipDetacher {\n return async ({ orgId, field, id }) => {\n // A site with no organization holds no sequences, and a container held\n // in any other field is not one a sequence joins: nothing to detach.\n if (!orgId || field !== CAMPAIGN_MEMBERSHIP_FIELD) return { detached: 0, remaining: false }\n const firestore = deps.firestore()\n const sequences = await detachFrom(outreachOrgCollection(firestore, orgId, 'sequences'), id)\n const enrollments = await detachFrom(outreachOrgCollection(firestore, orgId, 'enrollments'), id)\n return {\n detached: sequences.detached + enrollments.detached,\n remaining: sequences.remaining || enrollments.remaining,\n }\n }\n}\n"],"names":["CAMPAIGN_MEMBERSHIP_FIELD","FieldValue","outreachOrgCollection","DETACH_BATCH","DETACH_PASSES","detachFrom","collection","campaignId","firestore","detached","pass","page","where","limit","get","empty","remaining","batch","member","docs","update","ref","arrayRemove","commit","size","createOutreachCampaignDetacher","deps","orgId","field","id","sequences","enrollments"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,yBAAyB,QAAQ,6CAA4C;AAKtF,SAASC,UAAU,QAAQ,2BAA0B;AACrD,SAASC,qBAAqB,QAAQ,iCAA6B;AAEnE;;;;;;;;;;;;;;;;;;;CAmBC,GAED,wEAAwE,GACxE,MAAMC,eAAe;AAErB,qDAAqD,GACrD,MAAMC,gBAAgB;AAEtB,eAAeC,WACbC,UAAiD,EACjDC,UAAkB;IAElB,MAAMC,YAAYF,WAAWE,SAAS;IACtC,IAAIC,WAAW;IACf,IAAK,IAAIC,OAAO,GAAGA,OAAON,eAAeM,QAAQ,EAAG;QAClD,MAAMC,OAAO,MAAML,WAAWM,KAAK,CAACZ,2BAA2B,kBAAkBO,YAAYM,KAAK,CAACV,cAAcW,GAAG;QACpH,IAAIH,KAAKI,KAAK,EAAE,OAAO;YAAEN;YAAUO,WAAW;QAAM;QACpD,MAAMC,QAAQT,UAAUS,KAAK;QAC7B,KAAK,MAAMC,UAAUP,KAAKQ,IAAI,CAAE;YAC9BF,MAAMG,MAAM,CAACF,OAAOG,GAAG,EAAE;gBAAE,CAACrB,0BAA0B,EAAEC,WAAWqB,WAAW,CAACf;YAAY;QAC7F;QACA,MAAMU,MAAMM,MAAM;QAClBd,YAAYE,KAAKa,IAAI;QACrB,IAAIb,KAAKa,IAAI,GAAGrB,cAAc,OAAO;YAAEM;YAAUO,WAAW;QAAM;IACpE;IACA,OAAO;QAAEP;QAAUO,WAAW;IAAK;AACrC;AAEA,iDAAiD,GACjD,OAAO,SAASS,+BAA+BC,IAE9C;IACC,OAAO,OAAO,EAAEC,KAAK,EAAEC,KAAK,EAAEC,EAAE,EAAE;QAChC,uEAAuE;QACvE,qEAAqE;QACrE,IAAI,CAACF,SAASC,UAAU5B,2BAA2B,OAAO;YAAES,UAAU;YAAGO,WAAW;QAAM;QAC1F,MAAMR,YAAYkB,KAAKlB,SAAS;QAChC,MAAMsB,YAAY,MAAMzB,WAAWH,sBAAsBM,WAAWmB,OAAO,cAAcE;QACzF,MAAME,cAAc,MAAM1B,WAAWH,sBAAsBM,WAAWmB,OAAO,gBAAgBE;QAC7F,OAAO;YACLpB,UAAUqB,UAAUrB,QAAQ,GAAGsB,YAAYtB,QAAQ;YACnDO,WAAWc,UAAUd,SAAS,IAAIe,YAAYf,SAAS;QACzD;IACF;AACF"}
|
|
@@ -16,4 +16,4 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
18
|
import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
19
|
-
export declare function createOutreachClickRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>): PluginWebApiHandler;
|
|
19
|
+
export declare function createOutreachClickRoute(deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>): PluginWebApiHandler;
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* See the License for the specific language governing permissions and
|
|
15
15
|
* limitations under the License.
|
|
16
16
|
*/ import { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { recordOutreachSequenceTouch } from "./campaign-credit.js";
|
|
17
18
|
import { readOutreachClickToken } from "./click-link.js";
|
|
18
19
|
import { recordOutreachClick } from "./click-events.js";
|
|
19
20
|
// The plain page both recipient-facing routes answer with: no theme, no
|
|
@@ -91,11 +92,22 @@ export function createOutreachClickRoute(deps) {
|
|
|
91
92
|
})
|
|
92
93
|
});
|
|
93
94
|
try {
|
|
94
|
-
await recordOutreachClick(deps, {
|
|
95
|
+
const outcome = await recordOutreachClick(deps, {
|
|
95
96
|
target,
|
|
96
97
|
method: request.method,
|
|
97
98
|
userAgent: request.headers.get('user-agent')
|
|
98
99
|
});
|
|
100
|
+
/*
|
|
101
|
+
* A person's click — never a scanner's — on a sequence in a campaign
|
|
102
|
+
* is their last campaign touch on the site (AGL-3254), so the booking
|
|
103
|
+
* or the form they go on to make is credited to the sequence's
|
|
104
|
+
* campaign by the door that credits every other one.
|
|
105
|
+
*/ if (outcome.human && outcome.enrollment) {
|
|
106
|
+
await recordOutreachSequenceTouch(deps, {
|
|
107
|
+
enrollment: outcome.enrollment,
|
|
108
|
+
atMs: deps.now()
|
|
109
|
+
});
|
|
110
|
+
}
|
|
99
111
|
} catch (error) {
|
|
100
112
|
// The visit already has its answer. A click we failed to count is a
|
|
101
113
|
// missing number; a redirect we failed to send is a broken email.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { readOutreachClickToken } from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The destination is in the token, so answering needs no read: the handler\n * verifies the signature, and the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\nexport function createOutreachClickRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline'>,\n): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n }\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n }\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — the\n * token reader will return nothing else — so it is a header value that\n * cannot carry a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n try {\n await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n }\n}\n"],"names":["readOutreachClickToken","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","createOutreachClickRoute","deps","request","method","Response","status","headers","Allow","target","URL","url","searchParams","get","title","body","redirect","Location","userAgent","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,sBAAsB,QAAQ,kBAAc;AACrD,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAEA,OAAO,SAASC,yBACdC,
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/click-route.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport { recordOutreachSequenceTouch } from './campaign-credit'\nimport { readOutreachClickToken } from './click-link'\nimport { recordOutreachClick } from './click-events'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\n// The plain page both recipient-facing routes answer with: no theme, no\n// session, the reader's own colors. It lives beside the first route that\n// needed one rather than being copied for the second.\nimport { outreachUnsubscribePage } from './unsubscribe-route'\n\n/**\n * A LINK IN A TRACKED SEQUENCE EMAIL (AGL-3239): `GET /api/outreach/click?t=…`.\n *\n * ## The redirect happens first, and it happens for everyone\n *\n * The destination is in the token, so answering needs no read: the handler\n * verifies the signature, and the 302 goes out. Recording is awaited after\n * the answer is built and never gates it — a Firestore that is slow or down\n * costs a number, not a visit.\n *\n * A link scanner is redirected exactly as a person is. Refusing one, or\n * answering it differently, is how a security gateway learns to report the\n * link as broken and the message as suspicious; it is counted apart instead\n * (`../engine/click-tracking.ts`).\n *\n * ## No session, and no release gate\n *\n * Registered as a recipient link, like the one-click unsubscribe. The\n * signature in the token is the whole of the authority, and a link that is\n * already in somebody's inbox has to keep working whether or not Outreach is\n * released to that organization today.\n *\n * ## A token that does not verify is not followed\n *\n * There is nowhere to send them: the destination WAS the thing that failed\n * the check. So it answers the same plain page the unsubscribe route\n * answers, rather than guessing — an endpoint that redirects to an unsigned\n * URL is an open redirect, whatever it is called.\n *\n * ## What the recipient's browser is told\n *\n * `no-store`, so a gateway's copy is never served to the person after it;\n * `no-referrer`, so the destination never learns the token; and `noindex`,\n * because a tracking link that reached a crawler should not be in an index.\n */\n\nconst REDIRECT_HEADERS = {\n 'Cache-Control': 'no-store, no-cache, must-revalidate',\n 'Referrer-Policy': 'no-referrer',\n 'X-Robots-Tag': 'noindex, nofollow',\n}\n\nexport function createOutreachClickRoute(\n deps: Pick<OutreachRuntimeDeps, 'firestore' | 'now' | 'timeline' | 'campaignCredit'>,\n): PluginWebApiHandler {\n return async (request) => {\n // `HEAD` is answered because a gateway often sends one before the `GET`\n // a person makes; it is redirected and counted as the machine it is.\n if (request.method !== 'GET' && request.method !== 'HEAD') {\n return new Response('Method not allowed', { status: 405, headers: { Allow: 'GET, HEAD' } })\n }\n const target = readOutreachClickToken(new URL(request.url).searchParams.get('t'))\n if (!target) {\n return outreachUnsubscribePage(\n {\n title: 'This link doesn’t work',\n body: 'This link isn’t valid, or it has been changed since it was sent. Try opening it from the original email.',\n },\n 400,\n )\n }\n /*\n * Built by hand rather than with `Response.redirect`, which seals its\n * headers: the three above are the point of the answer as much as the\n * `Location` is. `target.url` is `URL.href` of an http(s) URL — the\n * token reader will return nothing else — so it is a header value that\n * cannot carry a newline into the response.\n */\n const redirect = new Response(null, {\n status: 302,\n headers: { ...REDIRECT_HEADERS, Location: target.url },\n })\n try {\n const outcome = await recordOutreachClick(deps, {\n target,\n method: request.method,\n userAgent: request.headers.get('user-agent'),\n })\n /*\n * A person's click — never a scanner's — on a sequence in a campaign\n * is their last campaign touch on the site (AGL-3254), so the booking\n * or the form they go on to make is credited to the sequence's\n * campaign by the door that credits every other one.\n */\n if (outcome.human && outcome.enrollment) {\n await recordOutreachSequenceTouch(deps, { enrollment: outcome.enrollment, atMs: deps.now() })\n }\n } catch (error) {\n // The visit already has its answer. A click we failed to count is a\n // missing number; a redirect we failed to send is a broken email.\n console.error('[outreach] a click could not be recorded', error)\n }\n return redirect\n }\n}\n"],"names":["recordOutreachSequenceTouch","readOutreachClickToken","recordOutreachClick","outreachUnsubscribePage","REDIRECT_HEADERS","createOutreachClickRoute","deps","request","method","Response","status","headers","Allow","target","URL","url","searchParams","get","title","body","redirect","Location","outcome","userAgent","human","enrollment","atMs","now","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAGD,SAASA,2BAA2B,QAAQ,uBAAmB;AAC/D,SAASC,sBAAsB,QAAQ,kBAAc;AACrD,SAASC,mBAAmB,QAAQ,oBAAgB;AAEpD,wEAAwE;AACxE,yEAAyE;AACzE,sDAAsD;AACtD,SAASC,uBAAuB,QAAQ,yBAAqB;AAE7D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCC,GAED,MAAMC,mBAAmB;IACvB,iBAAiB;IACjB,mBAAmB;IACnB,gBAAgB;AAClB;AAEA,OAAO,SAASC,yBACdC,IAAoF;IAEpF,OAAO,OAAOC;QACZ,wEAAwE;QACxE,qEAAqE;QACrE,IAAIA,QAAQC,MAAM,KAAK,SAASD,QAAQC,MAAM,KAAK,QAAQ;YACzD,OAAO,IAAIC,SAAS,sBAAsB;gBAAEC,QAAQ;gBAAKC,SAAS;oBAAEC,OAAO;gBAAY;YAAE;QAC3F;QACA,MAAMC,SAASZ,uBAAuB,IAAIa,IAAIP,QAAQQ,GAAG,EAAEC,YAAY,CAACC,GAAG,CAAC;QAC5E,IAAI,CAACJ,QAAQ;YACX,OAAOV,wBACL;gBACEe,OAAO;gBACPC,MAAM;YACR,GACA;QAEJ;QACA;;;;;;KAMC,GACD,MAAMC,WAAW,IAAIX,SAAS,MAAM;YAClCC,QAAQ;YACRC,SAAS,aAAKP;gBAAkBiB,UAAUR,OAAOE,GAAG;;QACtD;QACA,IAAI;YACF,MAAMO,UAAU,MAAMpB,oBAAoBI,MAAM;gBAC9CO;gBACAL,QAAQD,QAAQC,MAAM;gBACtBe,WAAWhB,QAAQI,OAAO,CAACM,GAAG,CAAC;YACjC;YACA;;;;;OAKC,GACD,IAAIK,QAAQE,KAAK,IAAIF,QAAQG,UAAU,EAAE;gBACvC,MAAMzB,4BAA4BM,MAAM;oBAAEmB,YAAYH,QAAQG,UAAU;oBAAEC,MAAMpB,KAAKqB,GAAG;gBAAG;YAC7F;QACF,EAAE,OAAOC,OAAO;YACd,oEAAoE;YACpE,kEAAkE;YAClEC,QAAQD,KAAK,CAAC,4CAA4CA;QAC5D;QACA,OAAOR;IACT;AACF"}
|
|
@@ -16,7 +16,14 @@
|
|
|
16
16
|
*/
|
|
17
17
|
import type { PluginWebApiHandler } from '@aglyn/aglyn/server';
|
|
18
18
|
import type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure';
|
|
19
|
-
import type { OutreachRuntimeDeps } from './runtime-deps';
|
|
19
|
+
import type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps';
|
|
20
|
+
/**
|
|
21
|
+
* The platform's campaign attribution, for what a sequence produced
|
|
22
|
+
* (AGL-3254): the same join every form, booking and campaign email is
|
|
23
|
+
* credited through, so a sequence's numbers and a campaign send's are two
|
|
24
|
+
* readings of one rule. Each writer already never throws.
|
|
25
|
+
*/
|
|
26
|
+
export declare function platformOutreachCampaignCredit(firestore: () => FirebaseFirestore.Firestore): OutreachCampaignCredit;
|
|
20
27
|
/**
|
|
21
28
|
* The platform's own reach for the sending runtime (AGL-2981). Loaded only
|
|
22
29
|
* when a job runs or the unsubscribe route answers — never at boot — so the
|
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
import { checkEntitlement } from "@aglyn/aglyn/app-utils/plan-entitlements";
|
|
18
18
|
import { isPluginEnabled } from "@aglyn/aglyn/plugin-manager/enabled-plugins";
|
|
19
19
|
import { pluginRecordTimelineWriter } from "@aglyn/aglyn/plugin-manager/plugin-record-timeline";
|
|
20
|
+
import { attributeCampaignConversion, creditCampaignSequenceOutcome } from "@aglyn/tenant-data-admin/server/campaign-conversion-attribution";
|
|
21
|
+
import { recordEmailCampaignTouch } from "@aglyn/tenant-data-admin/server/email-delivery-log";
|
|
20
22
|
import { suppressEmail } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
21
23
|
import { recordTopicOptOut } from "@aglyn/tenant-data-admin/server/email-topic-confirmation";
|
|
22
24
|
import { firebaseAdmin } from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
@@ -34,6 +36,48 @@ import { createOutreachClickRoute } from "./click-route.js";
|
|
|
34
36
|
import { createOutreachPersonEraser } from "./person-erasure.js";
|
|
35
37
|
import { outreachUnsubscribeUrl } from "./unsubscribe-link.js";
|
|
36
38
|
import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
39
|
+
/**
|
|
40
|
+
* The platform's campaign attribution, for what a sequence produced
|
|
41
|
+
* (AGL-3254): the same join every form, booking and campaign email is
|
|
42
|
+
* credited through, so a sequence's numbers and a campaign send's are two
|
|
43
|
+
* readings of one rule. Each writer already never throws.
|
|
44
|
+
*/ export function platformOutreachCampaignCredit(firestore) {
|
|
45
|
+
return {
|
|
46
|
+
async credit ({ hostId, campaignIds, outcome, atMs }) {
|
|
47
|
+
await creditCampaignSequenceOutcome({
|
|
48
|
+
hostId,
|
|
49
|
+
campaignIds,
|
|
50
|
+
outcome,
|
|
51
|
+
atMs
|
|
52
|
+
}, firestore());
|
|
53
|
+
},
|
|
54
|
+
async attributeRecord ({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {
|
|
55
|
+
await attributeCampaignConversion({
|
|
56
|
+
hostId,
|
|
57
|
+
kind,
|
|
58
|
+
refId,
|
|
59
|
+
touch: {
|
|
60
|
+
channel: 'sequence',
|
|
61
|
+
campaignId,
|
|
62
|
+
sequenceId,
|
|
63
|
+
enrollmentId,
|
|
64
|
+
touchedAtMs: atMs
|
|
65
|
+
},
|
|
66
|
+
convertedAtMs: atMs
|
|
67
|
+
}, firestore());
|
|
68
|
+
},
|
|
69
|
+
async recordTouch ({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {
|
|
70
|
+
await recordEmailCampaignTouch({
|
|
71
|
+
email,
|
|
72
|
+
hostId,
|
|
73
|
+
campaignId,
|
|
74
|
+
atMs,
|
|
75
|
+
sequenceId,
|
|
76
|
+
enrollmentId
|
|
77
|
+
}, firestore());
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
37
81
|
/**
|
|
38
82
|
* The platform's own reach for the sending runtime (AGL-2981). Loaded only
|
|
39
83
|
* when a job runs or the unsubscribe route answers — never at boot — so the
|
|
@@ -44,6 +88,7 @@ import { createOutreachUnsubscribeRoute } from "./unsubscribe-route.js";
|
|
|
44
88
|
firestore,
|
|
45
89
|
now: Date.now,
|
|
46
90
|
random: Math.random,
|
|
91
|
+
campaignCredit: platformOutreachCampaignCredit(firestore),
|
|
47
92
|
openMailbox: (mailboxId)=>openOutreachMailboxClient(firestore(), {
|
|
48
93
|
mailboxId
|
|
49
94
|
}),
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-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 { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachRuntimeDeps","firestore","app","now","Date","random","Math","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","hostId","email","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"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"}
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/outreach/src/lib/runtime/platform-runtime-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 { EMAIL_TOPIC_SALES } from '@aglyn/aglyn/app-utils/email-topics'\nimport { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements'\nimport { isPluginEnabled } from '@aglyn/aglyn/plugin-manager/enabled-plugins'\nimport { pluginRecordTimelineWriter } from '@aglyn/aglyn/plugin-manager/plugin-record-timeline'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n} from '@aglyn/tenant-data-admin/server/campaign-conversion-attribution'\nimport { recordEmailCampaignTouch } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { suppressEmail } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport { recordTopicOptOut } from '@aglyn/tenant-data-admin/server/email-topic-confirmation'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { getLockdownVerdict } from '@aglyn/tenant-data-admin/server/lockdown'\nimport { sendOrgMemberNotice } from '@aglyn/tenant-data-admin/server/org-member-notice'\nimport { logOrgActivity } from '@aglyn/tenant-data-admin/server/organizations'\nimport { filterEnabledPluginsByReleaseFlags } from '@aglyn/tenant-data-admin/server/release-flags'\nimport { stampRecordEmailState } from '@aglyn/aglyn/plugin-manager/plugin-record-email-state'\nimport { OUTREACH_PLUGIN_ID } from '../constants/bundle-common'\nimport { composeOutreachMailboxNotice } from '../engine/mailbox-notice'\nimport { openOutreachMailboxClient } from '../mailboxes/mailbox-transport'\nimport { canonicalConsoleOrigin } from '../mailboxes/oauth-redirect'\nimport type { PluginWebApiHandler } from '@aglyn/aglyn/server'\nimport type { PluginPersonEraser } from '@aglyn/aglyn/plugin-manager/plugin-person-erasure'\nimport { outreachClickUrl } from './click-link'\nimport { createOutreachClickRoute } from './click-route'\nimport { createOutreachPersonEraser } from './person-erasure'\nimport type { OutreachCampaignCredit, OutreachRuntimeDeps } from './runtime-deps'\nimport { outreachUnsubscribeUrl } from './unsubscribe-link'\nimport { createOutreachUnsubscribeRoute } from './unsubscribe-route'\n\n/**\n * The platform's campaign attribution, for what a sequence produced\n * (AGL-3254): the same join every form, booking and campaign email is\n * credited through, so a sequence's numbers and a campaign send's are two\n * readings of one rule. Each writer already never throws.\n */\nexport function platformOutreachCampaignCredit(\n firestore: () => FirebaseFirestore.Firestore,\n): OutreachCampaignCredit {\n return {\n async credit({ hostId, campaignIds, outcome, atMs }) {\n await creditCampaignSequenceOutcome({ hostId, campaignIds, outcome, atMs }, firestore())\n },\n async attributeRecord({ hostId, kind, refId, campaignId, sequenceId, enrollmentId, atMs }) {\n await attributeCampaignConversion(\n {\n hostId,\n kind,\n refId,\n touch: { channel: 'sequence', campaignId, sequenceId, enrollmentId, touchedAtMs: atMs },\n convertedAtMs: atMs,\n },\n firestore(),\n )\n },\n async recordTouch({ hostId, email, campaignId, sequenceId, enrollmentId, atMs }) {\n await recordEmailCampaignTouch({ email, hostId, campaignId, atMs, sequenceId, enrollmentId }, firestore())\n },\n }\n}\n\n/**\n * The platform's own reach for the sending runtime (AGL-2981). Loaded only\n * when a job runs or the unsubscribe route answers — never at boot — so the\n * console pays for these modules when Outreach does work.\n */\nexport function platformOutreachRuntimeDeps(): OutreachRuntimeDeps {\n const firestore = () => firebaseAdmin.app().firestore()\n return {\n firestore,\n now: Date.now,\n random: Math.random,\n campaignCredit: platformOutreachCampaignCredit(firestore),\n openMailbox: (mailboxId) => openOutreachMailboxClient(firestore(), { mailboxId }),\n async orgRefusal(orgId, org) {\n if (!isPluginEnabled(org as { enabledPlugins?: string[] }, OUTREACH_PLUGIN_ID)) return 'plugin-disabled'\n if (!checkEntitlement(org, 'outreach')) return 'not-entitled'\n const released = await filterEnabledPluginsByReleaseFlags([OUTREACH_PLUGIN_ID], { orgId })\n return released.length ? null : 'not-released'\n },\n async sendRefusal({ org, uid }) {\n const locked = await getLockdownVerdict({ org, uid, intent: 'write' })\n return locked ? `locked:${locked.scope}` : null\n },\n timeline: () => pluginRecordTimelineWriter()?.writer ?? null,\n logOrgActivity: (orgId, actor, action, target) => logOrgActivity(orgId, actor, action, target),\n async optOutOfSalesTopic({ hostId, email }) {\n await recordTopicOptOut(hostId, email, EMAIL_TOPIC_SALES, { firestore: firestore() })\n },\n async suppressBouncedEmail({ email, hostId }) {\n // The runtime stamps the record itself, with the richer verdict — the\n // domain block, the enrollment — so the list's own stamp stands down.\n await suppressEmail({ email, reason: 'bounce', context: 'outreach', hostId, stampRecord: false, firestore: firestore() })\n },\n async stampRecordEmailState(stamp) {\n await stampRecordEmailState(stamp)\n },\n async notifyMailboxOwner(notice) {\n // The Mailboxes page, by the organization's slug — the page Resume and\n // Reconnect live on (AGL-3244). Without a slug or a console origin the\n // notice still goes, and says where the page is instead.\n const org = await firestore().collection('orgs').doc(notice.orgId).get()\n const slug = typeof org.get('slug') === 'string' ? String(org.get('slug')) : ''\n const origin = canonicalConsoleOrigin()\n const mailboxesUrl = slug && origin ? `${origin}/${encodeURIComponent(slug)}/outreach/mailboxes` : null\n const email = composeOutreachMailboxNotice(notice, { mailboxesUrl })\n const result = await sendOrgMemberNotice({\n orgId: notice.orgId,\n uids: [notice.connectedByUid],\n includeAdmins: true,\n subject: email.subject,\n text: email.text,\n context: 'outreach-mailbox-notice',\n })\n if (!result.sent) console.warn(`[outreach] the mailbox owner was not emailed: ${result.reason}`)\n },\n unsubscribeUrl: (target) => outreachUnsubscribeUrl({ origin: canonicalConsoleOrigin(), target }),\n clickUrl: (target) => outreachClickUrl({ origin: canonicalConsoleOrigin(), target }),\n }\n}\n\n/** The unsubscribe route on the platform's own reach. */\nexport function platformOutreachUnsubscribeRoute(): PluginWebApiHandler {\n return createOutreachUnsubscribeRoute(platformOutreachRuntimeDeps())\n}\n\n/** The click-tracking redirect on the platform's own reach (AGL-3239). */\nexport function platformOutreachClickRoute(): PluginWebApiHandler {\n return createOutreachClickRoute(platformOutreachRuntimeDeps())\n}\n\n/** The person eraser on the platform's own Firestore. */\nexport function platformOutreachPersonEraser(): PluginPersonEraser {\n return createOutreachPersonEraser({ firestore: () => firebaseAdmin.app().firestore() })\n}\n"],"names":["EMAIL_TOPIC_SALES","checkEntitlement","isPluginEnabled","pluginRecordTimelineWriter","attributeCampaignConversion","creditCampaignSequenceOutcome","recordEmailCampaignTouch","suppressEmail","recordTopicOptOut","firebaseAdmin","getLockdownVerdict","sendOrgMemberNotice","logOrgActivity","filterEnabledPluginsByReleaseFlags","stampRecordEmailState","OUTREACH_PLUGIN_ID","composeOutreachMailboxNotice","openOutreachMailboxClient","canonicalConsoleOrigin","outreachClickUrl","createOutreachClickRoute","createOutreachPersonEraser","outreachUnsubscribeUrl","createOutreachUnsubscribeRoute","platformOutreachCampaignCredit","firestore","credit","hostId","campaignIds","outcome","atMs","attributeRecord","kind","refId","campaignId","sequenceId","enrollmentId","touch","channel","touchedAtMs","convertedAtMs","recordTouch","email","platformOutreachRuntimeDeps","app","now","Date","random","Math","campaignCredit","openMailbox","mailboxId","orgRefusal","orgId","org","released","length","sendRefusal","uid","locked","intent","scope","timeline","writer","actor","action","target","optOutOfSalesTopic","suppressBouncedEmail","reason","context","stampRecord","stamp","notifyMailboxOwner","notice","collection","doc","get","slug","String","origin","mailboxesUrl","encodeURIComponent","result","uids","connectedByUid","includeAdmins","subject","text","sent","console","warn","unsubscribeUrl","clickUrl","platformOutreachUnsubscribeRoute","platformOutreachClickRoute","platformOutreachPersonEraser"],"mappings":"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"}
|