@aglyn/plugins-marketing 1.0.0-beta.221 → 1.0.0-beta.222
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -1
- package/package.json +15 -15
- package/src/lib/components/campaign-conversions-card.js +53 -64
- package/src/lib/components/campaign-conversions-card.js.map +1 -1
- package/src/lib/components/campaign-detail-card.js +2 -1
- package/src/lib/components/campaign-detail-card.js.map +1 -1
- package/src/lib/components/campaign-edit-drawer.js +1 -1
- package/src/lib/components/campaign-edit-drawer.js.map +1 -1
- package/src/lib/components/campaign-queries.js +1 -1
- package/src/lib/components/campaign-queries.js.map +1 -1
- package/src/lib/components/campaign-reach-sections.js +1 -1
- package/src/lib/components/campaign-reach-sections.js.map +1 -1
- package/src/lib/components/campaign-report-card.js +5 -4
- package/src/lib/components/campaign-report-card.js.map +1 -1
- package/src/lib/components/campaigns-card.js +1 -1
- package/src/lib/components/campaigns-card.js.map +1 -1
- package/src/lib/components/email-compose-card.js +1 -1
- package/src/lib/components/email-compose-card.js.map +1 -1
- package/src/lib/components/email-detail.js +5 -4
- package/src/lib/components/email-detail.js.map +1 -1
- package/src/lib/components/email-template-report-card.d.ts +53 -0
- package/src/lib/components/email-template-report-card.js +469 -0
- package/src/lib/components/email-template-report-card.js.map +1 -0
- package/src/lib/components/emails-list-card.js +2 -2
- package/src/lib/components/emails-list-card.js.map +1 -1
- package/src/lib/components/host-experiments-card.component.js +3 -2
- package/src/lib/components/host-experiments-card.component.js.map +1 -1
- package/src/lib/components/site-runtime.js +3 -1
- package/src/lib/components/site-runtime.js.map +1 -1
- package/src/lib/declarations.server.d.ts +9 -0
- package/src/lib/declarations.server.js +62 -0
- package/src/lib/declarations.server.js.map +1 -1
- package/src/lib/model/campaign-container.d.ts +503 -0
- package/src/lib/model/campaign-container.js +498 -0
- package/src/lib/model/campaign-container.js.map +1 -0
- package/src/lib/model/campaign-conversions.d.ts +8 -1
- package/src/lib/model/campaign-conversions.js +11 -0
- package/src/lib/model/campaign-conversions.js.map +1 -1
- package/src/lib/model/campaign-report.d.ts +158 -0
- package/src/lib/model/campaign-report.js +268 -0
- package/src/lib/model/campaign-report.js.map +1 -0
- package/src/lib/model/campaign-revenue.d.ts +3 -3
- package/src/lib/model/campaign-revenue.js +3 -3
- package/src/lib/model/campaign-revenue.js.map +1 -1
- package/src/lib/model/campaign-send-time.js +2 -2
- package/src/lib/model/campaign-send-time.js.map +1 -1
- package/src/lib/model/compile-client-automations.d.ts +2 -2
- package/src/lib/model/compile-client-automations.js +3 -3
- package/src/lib/model/compile-client-automations.js.map +1 -1
- package/src/lib/model/email-record.d.ts +175 -0
- package/src/lib/model/email-record.js +206 -0
- package/src/lib/model/email-record.js.map +1 -0
- package/src/lib/model/site-contract.d.ts +2 -2
- package/src/lib/model/site-contract.js.map +1 -1
- package/src/lib/model/template-report.d.ts +158 -0
- package/src/lib/model/template-report.js +249 -0
- package/src/lib/model/template-report.js.map +1 -0
- package/src/lib/plugin.js +16 -0
- package/src/lib/plugin.js.map +1 -1
- package/src/lib/preview-props.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +55 -0
- package/src/lib/server/campaign-attribution-store.js +98 -0
- package/src/lib/server/campaign-attribution-store.js.map +1 -0
- package/src/lib/server/campaign-conversion-attribution.d.ts +283 -0
- package/src/lib/server/campaign-conversion-attribution.js +447 -0
- package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
- package/src/lib/server/campaign-manage.js +1 -1
- package/src/lib/server/campaign-manage.js.map +1 -1
- package/src/lib/server/campaign-org-refs.js +1 -1
- package/src/lib/server/campaign-org-refs.js.map +1 -1
- package/src/lib/server/campaign-recipients.js +7 -7
- package/src/lib/server/campaign-recipients.js.map +1 -1
- package/src/lib/server/campaign-send.js +25 -19
- package/src/lib/server/campaign-send.js.map +1 -1
- package/src/lib/server/conversion-creditor.d.ts +18 -0
- package/src/lib/server/conversion-creditor.js +131 -0
- package/src/lib/server/conversion-creditor.js.map +1 -0
- package/src/lib/server/email-campaign-touch.d.ts +81 -0
- package/src/lib/server/email-campaign-touch.js +227 -0
- package/src/lib/server/email-campaign-touch.js.map +1 -0
- package/src/lib/server/email-events.js +8 -6
- package/src/lib/server/email-events.js.map +1 -1
- package/src/lib/server/email-revenue-attribution.d.ts +186 -0
- package/src/lib/server/email-revenue-attribution.js +322 -0
- package/src/lib/server/email-revenue-attribution.js.map +1 -0
- package/src/lib/server/email-send-record-index.d.ts +34 -0
- package/src/lib/server/email-send-record-index.js +90 -0
- package/src/lib/server/email-send-record-index.js.map +1 -0
- package/src/lib/server/get-client-automations.js.map +1 -1
- package/src/lib/server/marketing-figures.js +3 -3
- package/src/lib/server/marketing-figures.js.map +1 -1
- package/src/lib/server/send-tally.d.ts +32 -0
- package/src/lib/server/send-tally.js +54 -0
- package/src/lib/server/send-tally.js.map +1 -0
- package/src/lib/testing/stand-in-person-records.d.ts +35 -0
- package/src/lib/testing/stand-in-person-records.js +67 -0
- package/src/lib/testing/stand-in-person-records.js.map +1 -0
- package/src/lib/testing/stand-in-record-counts.d.ts +26 -0
- package/src/lib/testing/stand-in-record-counts.js +70 -0
- package/src/lib/testing/stand-in-record-counts.js.map +1 -0
- package/src/lib/transfer/campaigns-package.d.ts +72 -0
- package/src/lib/transfer/campaigns-package.js +145 -0
- package/src/lib/transfer/campaigns-package.js.map +1 -0
- package/src/lib/transfer/campaigns-package.server.d.ts +33 -0
- package/src/lib/transfer/campaigns-package.server.js +305 -0
- package/src/lib/transfer/campaigns-package.server.js.map +1 -0
|
@@ -0,0 +1,18 @@
|
|
|
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 { PluginConversionCreditor } from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit';
|
|
18
|
+
export declare const marketingConversionCreditor: PluginConversionCreditor;
|
|
@@ -0,0 +1,131 @@
|
|
|
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 { _ as _extends } from "@swc/helpers/_/_extends";
|
|
17
|
+
import { CAMPAIGN_CONVERSION_KINDS } from "../model/campaign-conversions.js";
|
|
18
|
+
import { attributeCampaignConversion, creditCampaignSequenceOutcome, eraseCampaignAttributionsForPersonKey, resolveCampaignTouch } from "./campaign-conversion-attribution.js";
|
|
19
|
+
import { eraseEmailCampaignTouches, recordEmailCampaignTouch } from "./email-campaign-touch.js";
|
|
20
|
+
import { attributeOrderToEmail, reverseEmailAttributedRevenue } from "./email-revenue-attribution.js";
|
|
21
|
+
/**
|
|
22
|
+
* The Marketing plugin's conversion creditor: the platform's
|
|
23
|
+
* `plugin-conversion-credit` contract, answered by the campaign joins this
|
|
24
|
+
* plugin keeps.
|
|
25
|
+
*
|
|
26
|
+
* - a door's identify moment (a form, a lead, a contact, a booking) by the
|
|
27
|
+
* last-touch join (`campaign-conversion-attribution.ts`);
|
|
28
|
+
* - an order's money and its refund by the revenue join
|
|
29
|
+
* (`email-revenue-attribution.ts`);
|
|
30
|
+
* - a click on mail another plugin sent by the touch map
|
|
31
|
+
* (`email-campaign-touch.ts`), which a sequence's mail stamps with its
|
|
32
|
+
* sequence and enrollment;
|
|
33
|
+
* - what a sequence produced, counted under the campaigns it is filed in;
|
|
34
|
+
* - and the person's erasure, which takes the touches and every credit
|
|
35
|
+
* drawn from them.
|
|
36
|
+
*
|
|
37
|
+
* A touch leaves here as the join resolved it and comes back from the door
|
|
38
|
+
* unread, so a value is checked for the shape the join writes before it is
|
|
39
|
+
* credited: a door that handed something else hands nobody's credit.
|
|
40
|
+
*
|
|
41
|
+
* Loaded with the first credit, never at boot; every writer it reaches
|
|
42
|
+
* already never throws.
|
|
43
|
+
*/ const CHANNELS = [
|
|
44
|
+
'email',
|
|
45
|
+
'web',
|
|
46
|
+
'sequence'
|
|
47
|
+
];
|
|
48
|
+
/** A touch as this plugin resolved it, or `null` for anything else. */ function asResolvedTouch(value) {
|
|
49
|
+
if (!value || typeof value !== 'object') return null;
|
|
50
|
+
const channel = value['channel'];
|
|
51
|
+
const touchedAtMs = value['touchedAtMs'];
|
|
52
|
+
if (!CHANNELS.includes(channel)) return null;
|
|
53
|
+
if (typeof touchedAtMs !== 'number' || !Number.isFinite(touchedAtMs)) return null;
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
/** The touch a click on this or another plugin's mail is credited as. */ function touchOfClick(click) {
|
|
57
|
+
var _click_via, _click_via1;
|
|
58
|
+
const sequenceId = (_click_via = click.via) == null ? void 0 : _click_via['sequenceId'];
|
|
59
|
+
const enrollmentId = (_click_via1 = click.via) == null ? void 0 : _click_via1['enrollmentId'];
|
|
60
|
+
const viaSequence = Boolean(sequenceId && enrollmentId);
|
|
61
|
+
return _extends({
|
|
62
|
+
channel: viaSequence ? 'sequence' : 'email',
|
|
63
|
+
campaignId: click.creditTo
|
|
64
|
+
}, viaSequence ? {
|
|
65
|
+
sequenceId,
|
|
66
|
+
enrollmentId
|
|
67
|
+
} : {}, {
|
|
68
|
+
touchedAtMs: click.atMs
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const isConversionKind = (kind)=>CAMPAIGN_CONVERSION_KINDS.includes(kind);
|
|
72
|
+
export const marketingConversionCreditor = {
|
|
73
|
+
async resolveTouch (request) {
|
|
74
|
+
const touch = await resolveCampaignTouch(request);
|
|
75
|
+
return touch ? _extends({}, touch) : null;
|
|
76
|
+
},
|
|
77
|
+
async creditConversion (request) {
|
|
78
|
+
if (!isConversionKind(request.kind)) return false;
|
|
79
|
+
const touch = request.click ? touchOfClick(request.click) : asResolvedTouch(request.touch);
|
|
80
|
+
if (!touch) return false;
|
|
81
|
+
const record = await attributeCampaignConversion(_extends({
|
|
82
|
+
hostId: request.hostId,
|
|
83
|
+
kind: request.kind,
|
|
84
|
+
refId: request.refId,
|
|
85
|
+
touch
|
|
86
|
+
}, request.convertedAtMs !== undefined ? {
|
|
87
|
+
convertedAtMs: request.convertedAtMs
|
|
88
|
+
} : {}));
|
|
89
|
+
return record !== null;
|
|
90
|
+
},
|
|
91
|
+
async creditOrder (request) {
|
|
92
|
+
return await attributeOrderToEmail(request) !== null;
|
|
93
|
+
},
|
|
94
|
+
async reverseOrder (request) {
|
|
95
|
+
return reverseEmailAttributedRevenue(request);
|
|
96
|
+
},
|
|
97
|
+
async recordClick (click) {
|
|
98
|
+
var _click_email;
|
|
99
|
+
var _click_via, _click_via1;
|
|
100
|
+
const sequenceId = (_click_via = click.via) == null ? void 0 : _click_via['sequenceId'];
|
|
101
|
+
const enrollmentId = (_click_via1 = click.via) == null ? void 0 : _click_via1['enrollmentId'];
|
|
102
|
+
return recordEmailCampaignTouch(_extends({
|
|
103
|
+
email: typeof click.email === 'string' ? click.email : String((_click_email = click.email) != null ? _click_email : ''),
|
|
104
|
+
hostId: click.hostId,
|
|
105
|
+
campaignId: click.creditTo,
|
|
106
|
+
atMs: click.atMs
|
|
107
|
+
}, sequenceId && enrollmentId ? {
|
|
108
|
+
sequenceId,
|
|
109
|
+
enrollmentId
|
|
110
|
+
} : {}));
|
|
111
|
+
},
|
|
112
|
+
async creditOutcome (request) {
|
|
113
|
+
return creditCampaignSequenceOutcome(_extends({
|
|
114
|
+
hostId: request.hostId
|
|
115
|
+
}, request.orgId ? {
|
|
116
|
+
orgId: request.orgId
|
|
117
|
+
} : {}, {
|
|
118
|
+
campaignIds: request.containerIds,
|
|
119
|
+
outcome: request.outcome
|
|
120
|
+
}, request.atMs !== undefined ? {
|
|
121
|
+
atMs: request.atMs
|
|
122
|
+
} : {}));
|
|
123
|
+
},
|
|
124
|
+
async erasePerson (key) {
|
|
125
|
+
const touches = await eraseEmailCampaignTouches(key);
|
|
126
|
+
const credits = await eraseCampaignAttributionsForPersonKey(key);
|
|
127
|
+
return credits + (touches ? 1 : 0);
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
//# sourceMappingURL=conversion-creditor.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/conversion-creditor.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 {\n PluginConversionClick,\n PluginConversionCreditor,\n PluginConversionTouch,\n} from '@aglyn/aglyn/plugin-manager/plugin-conversion-credit'\nimport { CAMPAIGN_CONVERSION_KINDS, type CampaignConversionKind } from '../model/campaign-conversions'\nimport {\n attributeCampaignConversion,\n creditCampaignSequenceOutcome,\n eraseCampaignAttributionsForPersonKey,\n resolveCampaignTouch,\n type CampaignSequenceOutcome,\n type CampaignTouchChannel,\n type ResolvedCampaignTouch,\n} from './campaign-conversion-attribution'\nimport { eraseEmailCampaignTouches, recordEmailCampaignTouch } from './email-campaign-touch'\nimport { attributeOrderToEmail, reverseEmailAttributedRevenue } from './email-revenue-attribution'\n\n/**\n * The Marketing plugin's conversion creditor: the platform's\n * `plugin-conversion-credit` contract, answered by the campaign joins this\n * plugin keeps.\n *\n * - a door's identify moment (a form, a lead, a contact, a booking) by the\n * last-touch join (`campaign-conversion-attribution.ts`);\n * - an order's money and its refund by the revenue join\n * (`email-revenue-attribution.ts`);\n * - a click on mail another plugin sent by the touch map\n * (`email-campaign-touch.ts`), which a sequence's mail stamps with its\n * sequence and enrollment;\n * - what a sequence produced, counted under the campaigns it is filed in;\n * - and the person's erasure, which takes the touches and every credit\n * drawn from them.\n *\n * A touch leaves here as the join resolved it and comes back from the door\n * unread, so a value is checked for the shape the join writes before it is\n * credited: a door that handed something else hands nobody's credit.\n *\n * Loaded with the first credit, never at boot; every writer it reaches\n * already never throws.\n */\n\nconst CHANNELS: readonly CampaignTouchChannel[] = ['email', 'web', 'sequence']\n\n/** A touch as this plugin resolved it, or `null` for anything else. */\nfunction asResolvedTouch(value: PluginConversionTouch | null | undefined): ResolvedCampaignTouch | null {\n if (!value || typeof value !== 'object') return null\n const channel = value['channel']\n const touchedAtMs = value['touchedAtMs']\n if (!CHANNELS.includes(channel as CampaignTouchChannel)) return null\n if (typeof touchedAtMs !== 'number' || !Number.isFinite(touchedAtMs)) return null\n return value as unknown as ResolvedCampaignTouch\n}\n\n/** The touch a click on this or another plugin's mail is credited as. */\nfunction touchOfClick(click: PluginConversionClick): ResolvedCampaignTouch {\n const sequenceId = click.via?.['sequenceId']\n const enrollmentId = click.via?.['enrollmentId']\n const viaSequence = Boolean(sequenceId && enrollmentId)\n return {\n channel: viaSequence ? 'sequence' : 'email',\n campaignId: click.creditTo,\n ...(viaSequence ? { sequenceId, enrollmentId } : {}),\n touchedAtMs: click.atMs,\n }\n}\n\nconst isConversionKind = (kind: string): kind is CampaignConversionKind =>\n (CAMPAIGN_CONVERSION_KINDS as readonly string[]).includes(kind)\n\nexport const marketingConversionCreditor: PluginConversionCreditor = {\n async resolveTouch(request) {\n const touch = await resolveCampaignTouch(request)\n return touch ? ({ ...touch } as PluginConversionTouch) : null\n },\n\n async creditConversion(request) {\n if (!isConversionKind(request.kind)) return false\n const touch = request.click ? touchOfClick(request.click) : asResolvedTouch(request.touch)\n if (!touch) return false\n const record = await attributeCampaignConversion({\n hostId: request.hostId,\n kind: request.kind,\n refId: request.refId,\n touch,\n ...(request.convertedAtMs !== undefined ? { convertedAtMs: request.convertedAtMs } : {}),\n })\n return record !== null\n },\n\n async creditOrder(request) {\n return (await attributeOrderToEmail(request)) !== null\n },\n\n async reverseOrder(request) {\n return reverseEmailAttributedRevenue(request)\n },\n\n async recordClick(click) {\n const sequenceId = click.via?.['sequenceId']\n const enrollmentId = click.via?.['enrollmentId']\n return recordEmailCampaignTouch({\n email: typeof click.email === 'string' ? click.email : String(click.email ?? ''),\n hostId: click.hostId,\n campaignId: click.creditTo,\n atMs: click.atMs,\n ...(sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}),\n })\n },\n\n async creditOutcome(request) {\n return creditCampaignSequenceOutcome({\n hostId: request.hostId,\n ...(request.orgId ? { orgId: request.orgId } : {}),\n campaignIds: request.containerIds,\n outcome: request.outcome as CampaignSequenceOutcome,\n ...(request.atMs !== undefined ? { atMs: request.atMs } : {}),\n })\n },\n\n async erasePerson(key) {\n const touches = await eraseEmailCampaignTouches(key)\n const credits = await eraseCampaignAttributionsForPersonKey(key)\n return credits + (touches ? 1 : 0)\n },\n}\n"],"names":["CAMPAIGN_CONVERSION_KINDS","attributeCampaignConversion","creditCampaignSequenceOutcome","eraseCampaignAttributionsForPersonKey","resolveCampaignTouch","eraseEmailCampaignTouches","recordEmailCampaignTouch","attributeOrderToEmail","reverseEmailAttributedRevenue","CHANNELS","asResolvedTouch","value","channel","touchedAtMs","includes","Number","isFinite","touchOfClick","click","sequenceId","via","enrollmentId","viaSequence","Boolean","campaignId","creditTo","atMs","isConversionKind","kind","marketingConversionCreditor","resolveTouch","request","touch","creditConversion","record","hostId","refId","convertedAtMs","undefined","creditOrder","reverseOrder","recordClick","email","String","creditOutcome","orgId","campaignIds","containerIds","outcome","erasePerson","key","touches","credits"],"mappings":"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"}
|
|
@@ -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
|
+
/** The field on `emailDeliveries/{key}` holding the per-host touches. */
|
|
18
|
+
export declare const EMAIL_TOUCH_FIELD = "campaignTouches";
|
|
19
|
+
/**
|
|
20
|
+
* How many sites' touches one person's document keeps.
|
|
21
|
+
*
|
|
22
|
+
* A cap, not a page size: the map lives in a document with a 1 MiB ceiling
|
|
23
|
+
* and nothing else bounds how many sites may mail one address.
|
|
24
|
+
*/
|
|
25
|
+
export declare const EMAIL_TOUCH_MAX_HOSTS = 10;
|
|
26
|
+
/**
|
|
27
|
+
* The last campaign one person clicked on one site.
|
|
28
|
+
*
|
|
29
|
+
* A click on a SEQUENCE email (AGL-3254) is the same touch with two more
|
|
30
|
+
* facts: the sequence and the enrollment the email went out under. The
|
|
31
|
+
* campaign is then the container the sequence is in, and the identify
|
|
32
|
+
* moments this touch is credited to read as the sequence's rather than as
|
|
33
|
+
* a campaign send's.
|
|
34
|
+
*/
|
|
35
|
+
export interface EmailCampaignTouch {
|
|
36
|
+
hostId: string;
|
|
37
|
+
campaignId: string;
|
|
38
|
+
/** When the click happened, epoch ms — the provider's instant. */
|
|
39
|
+
clickedAtMs: number;
|
|
40
|
+
sequenceId?: string;
|
|
41
|
+
enrollmentId?: string;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Records that this person clicked this campaign's mail. Never throws.
|
|
45
|
+
*
|
|
46
|
+
* Forward-only, in a transaction, for the reason the delivery log's
|
|
47
|
+
* `recordPersonEngagement` is: provider delivery is at-least-once and unordered, so a replayed click
|
|
48
|
+
* from last month must not displace this week's. That same property is what
|
|
49
|
+
* makes this idempotent — a redelivered event finds its own instant already
|
|
50
|
+
* stored and writes nothing.
|
|
51
|
+
*
|
|
52
|
+
* @returns whether the touch moved forward.
|
|
53
|
+
*/
|
|
54
|
+
export declare function recordEmailCampaignTouch(touch: {
|
|
55
|
+
email: string | null | undefined;
|
|
56
|
+
hostId: string;
|
|
57
|
+
campaignId: string;
|
|
58
|
+
atMs: number;
|
|
59
|
+
/** Both or neither: a sequence click names the enrollment it came through. */
|
|
60
|
+
sequenceId?: string;
|
|
61
|
+
enrollmentId?: string;
|
|
62
|
+
}, firestore?: any): Promise<boolean>;
|
|
63
|
+
/**
|
|
64
|
+
* The last campaign this person clicked on this site, or `null`.
|
|
65
|
+
*
|
|
66
|
+
* One keyed document read — no query, no index, and nothing that can be
|
|
67
|
+
* truncated. `null` for an address we hold no touch for AND for a read that
|
|
68
|
+
* failed, which are the same answer on purpose: both mean "we cannot say
|
|
69
|
+
* which campaign preceded this order", and the only safe thing to do with
|
|
70
|
+
* that is credit nobody.
|
|
71
|
+
*/
|
|
72
|
+
export declare function readEmailCampaignTouch(email: string | null | undefined, hostId: string, firestore?: any): Promise<EmailCampaignTouch | null>;
|
|
73
|
+
/**
|
|
74
|
+
* Forgets every site's touch for one person, by `emailSuppressionKey` — the
|
|
75
|
+
* erasure's half of this module. Never throws; answers whether the field
|
|
76
|
+
* was on the document to delete.
|
|
77
|
+
*
|
|
78
|
+
* `update()` rather than a merge-set: a person the log never held has no
|
|
79
|
+
* document, and an erasure must not leave one behind holding a deletion.
|
|
80
|
+
*/
|
|
81
|
+
export declare function eraseEmailCampaignTouches(key: string | null | undefined, firestore?: any): Promise<boolean>;
|
|
@@ -0,0 +1,227 @@
|
|
|
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 { FieldValue } from "firebase-admin/firestore";
|
|
18
|
+
import { EMAIL_DELIVERIES_COLLECTION } from "@aglyn/tenant-data-admin/server/email-delivery-log";
|
|
19
|
+
import { emailSuppressionKey } from "@aglyn/tenant-data-admin/server/email-suppression";
|
|
20
|
+
import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
|
|
21
|
+
const defaultFirestore = ()=>firebaseAdmin.app().firestore();
|
|
22
|
+
/*==========================================
|
|
23
|
+
* THE CAMPAIGN TOUCH — which campaign this person last CLICKED, per site.
|
|
24
|
+
*
|
|
25
|
+
* The delivery log's engagement rollup (`email-delivery-log.ts`) answers "is
|
|
26
|
+
* this person still listening". It cannot answer "which email brought them
|
|
27
|
+
* here", because it keeps instants and not identities, and that second
|
|
28
|
+
* question is what attribution is: an order or a sign-up arrives, and
|
|
29
|
+
* something has to say which campaign preceded it.
|
|
30
|
+
*
|
|
31
|
+
* ## On the person's own delivery document
|
|
32
|
+
*
|
|
33
|
+
* The alternative was a per-host collection of touch documents, and it fails
|
|
34
|
+
* on erasure: an erasure arrives as an ADDRESS and knows nothing about which
|
|
35
|
+
* sites have mailed it, so a per-host collection would be a record of a
|
|
36
|
+
* person's clicks that an erasure request could not reach. On the person's
|
|
37
|
+
* document in the platform's delivery log (`emailDeliveries/{key}`) it is
|
|
38
|
+
* one field, keyed by the same hash every suppression list uses. The
|
|
39
|
+
* platform's address erasure asks this plugin to forget the person through
|
|
40
|
+
* the conversion-credit contract (`plugin-conversion-credit.ts`), and
|
|
41
|
+
* {@link eraseEmailCampaignTouches} deletes the field — a click is the same
|
|
42
|
+
* personal fact as the open recorded next to it.
|
|
43
|
+
*
|
|
44
|
+
* ## A CLICK ONLY
|
|
45
|
+
*
|
|
46
|
+
* The delivery log's `ENGAGEMENT_TYPES` include opens because the control
|
|
47
|
+
* they feed REFUSES to mail people, and the generous signal is the correct
|
|
48
|
+
* one for a refusal. This is the opposite kind of decision — it CREDITS a
|
|
49
|
+
* campaign with money — so it takes the strict signal. Since Apple's Mail Privacy Protection an open is
|
|
50
|
+
* substantially a statement about the recipient's mail client, and crediting
|
|
51
|
+
* revenue to one would credit whichever campaign most recently reached an
|
|
52
|
+
* Apple Mail user with orders from people who never read it.
|
|
53
|
+
*
|
|
54
|
+
* ## Per host, and capped
|
|
55
|
+
*
|
|
56
|
+
* A single global touch would credit site A's campaign with site B's order,
|
|
57
|
+
* or refuse both — the send path refuses cross-site reach and the revenue
|
|
58
|
+
* join has to agree with it. So the field is a map keyed by host, and a map
|
|
59
|
+
* on a document has to be bounded: past {@link EMAIL_TOUCH_MAX_HOSTS} the
|
|
60
|
+
* oldest touch is evicted, inside the transaction the forward-only rule
|
|
61
|
+
* already pays for. A person who clicks mail from eleven different sites
|
|
62
|
+
* loses their oldest click, which costs an attribution rather than a fact
|
|
63
|
+
* anybody else reads.
|
|
64
|
+
*=========================================*/ /** The field on `emailDeliveries/{key}` holding the per-host touches. */ export const EMAIL_TOUCH_FIELD = 'campaignTouches';
|
|
65
|
+
/**
|
|
66
|
+
* How many sites' touches one person's document keeps.
|
|
67
|
+
*
|
|
68
|
+
* A cap, not a page size: the map lives in a document with a 1 MiB ceiling
|
|
69
|
+
* and nothing else bounds how many sites may mail one address.
|
|
70
|
+
*/ export const EMAIL_TOUCH_MAX_HOSTS = 10;
|
|
71
|
+
/** Reads the touch map off a person document's data, defensively. */ function touchesFrom(data) {
|
|
72
|
+
const raw = data == null ? void 0 : data[EMAIL_TOUCH_FIELD];
|
|
73
|
+
if (!raw || typeof raw !== 'object') return {};
|
|
74
|
+
const found = {};
|
|
75
|
+
for (const [hostId, entry] of Object.entries(raw)){
|
|
76
|
+
var _ref, _ref1, _ref2, _ref3;
|
|
77
|
+
const campaignId = String((_ref = entry == null ? void 0 : entry.campaignId) != null ? _ref : '');
|
|
78
|
+
const atMs = Number((_ref1 = entry == null ? void 0 : entry.atMs) != null ? _ref1 : 0);
|
|
79
|
+
if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue;
|
|
80
|
+
const sequenceId = String((_ref2 = entry == null ? void 0 : entry.sequenceId) != null ? _ref2 : '');
|
|
81
|
+
const enrollmentId = String((_ref3 = entry == null ? void 0 : entry.enrollmentId) != null ? _ref3 : '');
|
|
82
|
+
found[hostId] = _extends({
|
|
83
|
+
campaignId,
|
|
84
|
+
atMs
|
|
85
|
+
}, sequenceId && enrollmentId ? {
|
|
86
|
+
sequenceId,
|
|
87
|
+
enrollmentId
|
|
88
|
+
} : {});
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Records that this person clicked this campaign's mail. Never throws.
|
|
94
|
+
*
|
|
95
|
+
* Forward-only, in a transaction, for the reason the delivery log's
|
|
96
|
+
* `recordPersonEngagement` is: provider delivery is at-least-once and unordered, so a replayed click
|
|
97
|
+
* from last month must not displace this week's. That same property is what
|
|
98
|
+
* makes this idempotent — a redelivered event finds its own instant already
|
|
99
|
+
* stored and writes nothing.
|
|
100
|
+
*
|
|
101
|
+
* @returns whether the touch moved forward.
|
|
102
|
+
*/ export async function recordEmailCampaignTouch(touch, firestore) {
|
|
103
|
+
var _touch_hostId, _touch_campaignId, _touch_sequenceId, _touch_enrollmentId;
|
|
104
|
+
const key = emailSuppressionKey(touch.email);
|
|
105
|
+
const hostId = String((_touch_hostId = touch.hostId) != null ? _touch_hostId : '');
|
|
106
|
+
const campaignId = String((_touch_campaignId = touch.campaignId) != null ? _touch_campaignId : '');
|
|
107
|
+
const atMs = Number(touch.atMs);
|
|
108
|
+
if (!key || !hostId || !campaignId) return false;
|
|
109
|
+
if (!Number.isFinite(atMs) || atMs <= 0) return false;
|
|
110
|
+
const sequenceId = String((_touch_sequenceId = touch.sequenceId) != null ? _touch_sequenceId : '');
|
|
111
|
+
const enrollmentId = String((_touch_enrollmentId = touch.enrollmentId) != null ? _touch_enrollmentId : '');
|
|
112
|
+
const viaSequence = sequenceId && enrollmentId ? {
|
|
113
|
+
sequenceId,
|
|
114
|
+
enrollmentId
|
|
115
|
+
} : {};
|
|
116
|
+
try {
|
|
117
|
+
const db = firestore != null ? firestore : defaultFirestore();
|
|
118
|
+
const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key);
|
|
119
|
+
let moved = false;
|
|
120
|
+
await db.runTransaction(async (transaction)=>{
|
|
121
|
+
var _ref;
|
|
122
|
+
moved = false;
|
|
123
|
+
const snapshot = await transaction.get(ref);
|
|
124
|
+
const stored = touchesFrom((_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {});
|
|
125
|
+
const held = stored[hostId];
|
|
126
|
+
// Not newer than what is already there, so nothing is written. An
|
|
127
|
+
// out-of-order or replayed event is the ordinary case this skips.
|
|
128
|
+
if (held && held.atMs >= atMs) return;
|
|
129
|
+
/*
|
|
130
|
+
* A merge-set merges nested maps at depth, so a campaign click after a
|
|
131
|
+
* sequence click would keep the sequence's ids beside the new campaign
|
|
132
|
+
* unless they are deleted by name: the two are written as the value
|
|
133
|
+
* or as `FieldValue.delete()` whenever the held entry carried them.
|
|
134
|
+
*/ const dropSequence = (held == null ? void 0 : held.sequenceId) && !('sequenceId' in viaSequence) ? {
|
|
135
|
+
sequenceId: FieldValue.delete(),
|
|
136
|
+
enrollmentId: FieldValue.delete()
|
|
137
|
+
} : {};
|
|
138
|
+
const update = {
|
|
139
|
+
[hostId]: _extends({
|
|
140
|
+
campaignId,
|
|
141
|
+
atMs
|
|
142
|
+
}, viaSequence, dropSequence)
|
|
143
|
+
};
|
|
144
|
+
/*
|
|
145
|
+
* EVICTION, and only when this host is NEW to the map. Replacing an
|
|
146
|
+
* existing host's touch cannot grow it, so the cap is checked exactly
|
|
147
|
+
* where the map can cross it. The oldest goes, because the window makes
|
|
148
|
+
* an old touch the one least likely to be credited with anything.
|
|
149
|
+
*
|
|
150
|
+
* `FieldValue.delete()` INSIDE the map: a merge-set merges nested maps
|
|
151
|
+
* at depth, which is what keeps every other host's touch — and is also
|
|
152
|
+
* why an evicted key has to be deleted explicitly rather than by
|
|
153
|
+
* omission.
|
|
154
|
+
*/ if (!held && Object.keys(stored).length >= EMAIL_TOUCH_MAX_HOSTS) {
|
|
155
|
+
const oldest = Object.entries(stored).sort((a, b)=>a[1].atMs - b[1].atMs || a[0].localeCompare(b[0]))[0];
|
|
156
|
+
if (oldest) update[oldest[0]] = FieldValue.delete();
|
|
157
|
+
}
|
|
158
|
+
transaction.set(ref, {
|
|
159
|
+
[EMAIL_TOUCH_FIELD]: update,
|
|
160
|
+
updatedAt: FieldValue.serverTimestamp()
|
|
161
|
+
}, {
|
|
162
|
+
merge: true
|
|
163
|
+
});
|
|
164
|
+
moved = true;
|
|
165
|
+
});
|
|
166
|
+
return moved;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
console.error('[email-campaign-touch] campaign touch write failed', error);
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The last campaign this person clicked on this site, or `null`.
|
|
174
|
+
*
|
|
175
|
+
* One keyed document read — no query, no index, and nothing that can be
|
|
176
|
+
* truncated. `null` for an address we hold no touch for AND for a read that
|
|
177
|
+
* failed, which are the same answer on purpose: both mean "we cannot say
|
|
178
|
+
* which campaign preceded this order", and the only safe thing to do with
|
|
179
|
+
* that is credit nobody.
|
|
180
|
+
*/ export async function readEmailCampaignTouch(email, hostId, firestore) {
|
|
181
|
+
const key = emailSuppressionKey(email);
|
|
182
|
+
if (!key || !hostId) return null;
|
|
183
|
+
try {
|
|
184
|
+
var _snapshot_data;
|
|
185
|
+
const db = firestore != null ? firestore : defaultFirestore();
|
|
186
|
+
const snapshot = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get();
|
|
187
|
+
const held = touchesFrom((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {})[hostId];
|
|
188
|
+
if (!held) return null;
|
|
189
|
+
return _extends({
|
|
190
|
+
hostId,
|
|
191
|
+
campaignId: held.campaignId,
|
|
192
|
+
clickedAtMs: held.atMs
|
|
193
|
+
}, held.sequenceId && held.enrollmentId ? {
|
|
194
|
+
sequenceId: held.sequenceId,
|
|
195
|
+
enrollmentId: held.enrollmentId
|
|
196
|
+
} : {});
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.error('[email-campaign-touch] campaign touch read failed', error);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Forgets every site's touch for one person, by `emailSuppressionKey` — the
|
|
204
|
+
* erasure's half of this module. Never throws; answers whether the field
|
|
205
|
+
* was on the document to delete.
|
|
206
|
+
*
|
|
207
|
+
* `update()` rather than a merge-set: a person the log never held has no
|
|
208
|
+
* document, and an erasure must not leave one behind holding a deletion.
|
|
209
|
+
*/ export async function eraseEmailCampaignTouches(key, firestore) {
|
|
210
|
+
if (!key || typeof key !== 'string') return false;
|
|
211
|
+
try {
|
|
212
|
+
var _snapshot_data;
|
|
213
|
+
const db = firestore != null ? firestore : defaultFirestore();
|
|
214
|
+
const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key);
|
|
215
|
+
const snapshot = await ref.get();
|
|
216
|
+
if (!snapshot.exists || !(EMAIL_TOUCH_FIELD in ((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {}))) return false;
|
|
217
|
+
await ref.update({
|
|
218
|
+
[EMAIL_TOUCH_FIELD]: FieldValue.delete()
|
|
219
|
+
});
|
|
220
|
+
return true;
|
|
221
|
+
} catch (error) {
|
|
222
|
+
console.error('[email-campaign-touch] erasure failed', error);
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
//# sourceMappingURL=email-campaign-touch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-campaign-touch.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 { FieldValue } from 'firebase-admin/firestore'\nimport { EMAIL_DELIVERIES_COLLECTION } from '@aglyn/tenant-data-admin/server/email-delivery-log'\nimport { emailSuppressionKey } from '@aglyn/tenant-data-admin/server/email-suppression'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/*==========================================\n * THE CAMPAIGN TOUCH — which campaign this person last CLICKED, per site.\n *\n * The delivery log's engagement rollup (`email-delivery-log.ts`) answers \"is\n * this person still listening\". It cannot answer \"which email brought them\n * here\", because it keeps instants and not identities, and that second\n * question is what attribution is: an order or a sign-up arrives, and\n * something has to say which campaign preceded it.\n *\n * ## On the person's own delivery document\n *\n * The alternative was a per-host collection of touch documents, and it fails\n * on erasure: an erasure arrives as an ADDRESS and knows nothing about which\n * sites have mailed it, so a per-host collection would be a record of a\n * person's clicks that an erasure request could not reach. On the person's\n * document in the platform's delivery log (`emailDeliveries/{key}`) it is\n * one field, keyed by the same hash every suppression list uses. The\n * platform's address erasure asks this plugin to forget the person through\n * the conversion-credit contract (`plugin-conversion-credit.ts`), and\n * {@link eraseEmailCampaignTouches} deletes the field — a click is the same\n * personal fact as the open recorded next to it.\n *\n * ## A CLICK ONLY\n *\n * The delivery log's `ENGAGEMENT_TYPES` include opens because the control\n * they feed REFUSES to mail people, and the generous signal is the correct\n * one for a refusal. This is the opposite kind of decision — it CREDITS a\n * campaign with money — so it takes the strict signal. Since Apple's Mail Privacy Protection an open is\n * substantially a statement about the recipient's mail client, and crediting\n * revenue to one would credit whichever campaign most recently reached an\n * Apple Mail user with orders from people who never read it.\n *\n * ## Per host, and capped\n *\n * A single global touch would credit site A's campaign with site B's order,\n * or refuse both — the send path refuses cross-site reach and the revenue\n * join has to agree with it. So the field is a map keyed by host, and a map\n * on a document has to be bounded: past {@link EMAIL_TOUCH_MAX_HOSTS} the\n * oldest touch is evicted, inside the transaction the forward-only rule\n * already pays for. A person who clicks mail from eleven different sites\n * loses their oldest click, which costs an attribution rather than a fact\n * anybody else reads.\n *=========================================*/\n\n/** The field on `emailDeliveries/{key}` holding the per-host touches. */\nexport const EMAIL_TOUCH_FIELD = 'campaignTouches'\n\n/**\n * How many sites' touches one person's document keeps.\n *\n * A cap, not a page size: the map lives in a document with a 1 MiB ceiling\n * and nothing else bounds how many sites may mail one address.\n */\nexport const EMAIL_TOUCH_MAX_HOSTS = 10\n\n/**\n * The last campaign one person clicked on one site.\n *\n * A click on a SEQUENCE email (AGL-3254) is the same touch with two more\n * facts: the sequence and the enrollment the email went out under. The\n * campaign is then the container the sequence is in, and the identify\n * moments this touch is credited to read as the sequence's rather than as\n * a campaign send's.\n */\nexport interface EmailCampaignTouch {\n hostId: string\n campaignId: string\n /** When the click happened, epoch ms — the provider's instant. */\n clickedAtMs: number\n sequenceId?: string\n enrollmentId?: string\n}\n\n/** One host's entry in the touch map, as stored. */\ninterface StoredTouch {\n campaignId: string\n atMs: number\n sequenceId?: string\n enrollmentId?: string\n}\n\n/** Reads the touch map off a person document's data, defensively. */\nfunction touchesFrom(\n data: Record<string, unknown> | null | undefined,\n): Record<string, StoredTouch> {\n const raw = data?.[EMAIL_TOUCH_FIELD]\n if (!raw || typeof raw !== 'object') return {}\n const found: Record<string, StoredTouch> = {}\n for (const [hostId, entry] of Object.entries(\n raw as Record<\n string,\n { campaignId?: unknown; atMs?: unknown; sequenceId?: unknown; enrollmentId?: unknown }\n >,\n )) {\n const campaignId = String(entry?.campaignId ?? '')\n const atMs = Number(entry?.atMs ?? 0)\n if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue\n const sequenceId = String(entry?.sequenceId ?? '')\n const enrollmentId = String(entry?.enrollmentId ?? '')\n found[hostId] = {\n campaignId,\n atMs,\n ...(sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}),\n }\n }\n return found\n}\n\n/**\n * Records that this person clicked this campaign's mail. Never throws.\n *\n * Forward-only, in a transaction, for the reason the delivery log's\n * `recordPersonEngagement` is: provider delivery is at-least-once and unordered, so a replayed click\n * from last month must not displace this week's. That same property is what\n * makes this idempotent — a redelivered event finds its own instant already\n * stored and writes nothing.\n *\n * @returns whether the touch moved forward.\n */\nexport async function recordEmailCampaignTouch(\n touch: {\n email: string | null | undefined\n hostId: string\n campaignId: string\n atMs: number\n /** Both or neither: a sequence click names the enrollment it came through. */\n sequenceId?: string\n enrollmentId?: string\n },\n firestore?: any,\n): Promise<boolean> {\n const key = emailSuppressionKey(touch.email)\n const hostId = String(touch.hostId ?? '')\n const campaignId = String(touch.campaignId ?? '')\n const atMs = Number(touch.atMs)\n if (!key || !hostId || !campaignId) return false\n if (!Number.isFinite(atMs) || atMs <= 0) return false\n const sequenceId = String(touch.sequenceId ?? '')\n const enrollmentId = String(touch.enrollmentId ?? '')\n const viaSequence = sequenceId && enrollmentId ? { sequenceId, enrollmentId } : {}\n\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key)\n let moved = false\n await db.runTransaction(async (transaction: any) => {\n moved = false\n const snapshot = await transaction.get(ref)\n const stored = touchesFrom(\n (snapshot.exists ? snapshot.data() : null) ?? {},\n )\n const held = stored[hostId]\n // Not newer than what is already there, so nothing is written. An\n // out-of-order or replayed event is the ordinary case this skips.\n if (held && held.atMs >= atMs) return\n\n /*\n * A merge-set merges nested maps at depth, so a campaign click after a\n * sequence click would keep the sequence's ids beside the new campaign\n * unless they are deleted by name: the two are written as the value\n * or as `FieldValue.delete()` whenever the held entry carried them.\n */\n const dropSequence =\n held?.sequenceId && !('sequenceId' in viaSequence)\n ? { sequenceId: FieldValue.delete(), enrollmentId: FieldValue.delete() }\n : {}\n const update: Record<string, unknown> = {\n [hostId]: { campaignId, atMs, ...viaSequence, ...dropSequence },\n }\n /*\n * EVICTION, and only when this host is NEW to the map. Replacing an\n * existing host's touch cannot grow it, so the cap is checked exactly\n * where the map can cross it. The oldest goes, because the window makes\n * an old touch the one least likely to be credited with anything.\n *\n * `FieldValue.delete()` INSIDE the map: a merge-set merges nested maps\n * at depth, which is what keeps every other host's touch — and is also\n * why an evicted key has to be deleted explicitly rather than by\n * omission.\n */\n if (!held && Object.keys(stored).length >= EMAIL_TOUCH_MAX_HOSTS) {\n const oldest = Object.entries(stored).sort(\n (a, b) => a[1].atMs - b[1].atMs || a[0].localeCompare(b[0]),\n )[0]\n if (oldest) update[oldest[0]] = FieldValue.delete()\n }\n\n transaction.set(\n ref,\n { [EMAIL_TOUCH_FIELD]: update, updatedAt: FieldValue.serverTimestamp() },\n { merge: true },\n )\n moved = true\n })\n return moved\n } catch (error) {\n console.error('[email-campaign-touch] campaign touch write failed', error)\n return false\n }\n}\n\n/**\n * The last campaign this person clicked on this site, or `null`.\n *\n * One keyed document read — no query, no index, and nothing that can be\n * truncated. `null` for an address we hold no touch for AND for a read that\n * failed, which are the same answer on purpose: both mean \"we cannot say\n * which campaign preceded this order\", and the only safe thing to do with\n * that is credit nobody.\n */\nexport async function readEmailCampaignTouch(\n email: string | null | undefined,\n hostId: string,\n firestore?: any,\n): Promise<EmailCampaignTouch | null> {\n const key = emailSuppressionKey(email)\n if (!key || !hostId) return null\n try {\n const db = firestore ?? defaultFirestore()\n const snapshot = await db\n .collection(EMAIL_DELIVERIES_COLLECTION)\n .doc(key)\n .get()\n const held = touchesFrom(snapshot.data() ?? {})[hostId]\n if (!held) return null\n return {\n hostId,\n campaignId: held.campaignId,\n clickedAtMs: held.atMs,\n ...(held.sequenceId && held.enrollmentId\n ? { sequenceId: held.sequenceId, enrollmentId: held.enrollmentId }\n : {}),\n }\n } catch (error) {\n console.error('[email-campaign-touch] campaign touch read failed', error)\n return null\n }\n}\n\n/**\n * Forgets every site's touch for one person, by `emailSuppressionKey` — the\n * erasure's half of this module. Never throws; answers whether the field\n * was on the document to delete.\n *\n * `update()` rather than a merge-set: a person the log never held has no\n * document, and an erasure must not leave one behind holding a deletion.\n */\nexport async function eraseEmailCampaignTouches(\n key: string | null | undefined,\n firestore?: any,\n): Promise<boolean> {\n if (!key || typeof key !== 'string') return false\n try {\n const db = firestore ?? defaultFirestore()\n const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key)\n const snapshot = await ref.get()\n if (!snapshot.exists || !(EMAIL_TOUCH_FIELD in (snapshot.data() ?? {}))) return false\n await ref.update({ [EMAIL_TOUCH_FIELD]: FieldValue.delete() })\n return true\n } catch (error) {\n console.error('[email-campaign-touch] erasure failed', error)\n return false\n }\n}\n"],"names":["FieldValue","EMAIL_DELIVERIES_COLLECTION","emailSuppressionKey","firebaseAdmin","defaultFirestore","app","firestore","EMAIL_TOUCH_FIELD","EMAIL_TOUCH_MAX_HOSTS","touchesFrom","data","raw","found","hostId","entry","Object","entries","campaignId","String","atMs","Number","isFinite","sequenceId","enrollmentId","recordEmailCampaignTouch","touch","key","email","viaSequence","db","ref","collection","doc","moved","runTransaction","transaction","snapshot","get","stored","exists","held","dropSequence","delete","update","keys","length","oldest","sort","a","b","localeCompare","set","updatedAt","serverTimestamp","merge","error","console","readEmailCampaignTouch","clickedAtMs","eraseEmailCampaignTouches"],"mappings":"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"}
|
|
@@ -38,7 +38,9 @@ import { emailSearchTokens, suppressEmail } from "@aglyn/tenant-data-admin/serve
|
|
|
38
38
|
// Same leaf-import reasoning again: the per-recipient delivery log is the only
|
|
39
39
|
// record staff have of what we sent someone, and a mocked-away writer is a
|
|
40
40
|
// green test over an empty log.
|
|
41
|
-
import {
|
|
41
|
+
import { recordEmailDeliveryEvents, recordPersonEngagement } from "@aglyn/tenant-data-admin/server/email-delivery-log";
|
|
42
|
+
// This plugin's touch map, on the same person document.
|
|
43
|
+
import { recordEmailCampaignTouch } from "./email-campaign-touch.js";
|
|
42
44
|
// The contact's own stamp is the record system's, asked through the core's
|
|
43
45
|
// seam: what the record page, the list and the re-engagement audience read.
|
|
44
46
|
import { stampRecordEmailEngagement } from "@aglyn/aglyn/plugin-manager/plugin-record-email-state";
|
|
@@ -48,7 +50,7 @@ import { getOrgForHost } from "@aglyn/tenant-data-admin/server/organizations";
|
|
|
48
50
|
// The leaf again: which document a delivery event counts against is the
|
|
49
51
|
// question every counter below depends on, and a stub would answer it for
|
|
50
52
|
// them.
|
|
51
|
-
import { resolveCampaignSendRef } from "
|
|
53
|
+
import { resolveCampaignSendRef } from "./campaign-conversion-attribution.js";
|
|
52
54
|
import { recordEmailReputationFailure } from "@aglyn/tenant-data-admin/server/email-sender-reputation";
|
|
53
55
|
// The leaf again: the ledger is what holds a sending domain's bulk mail, and
|
|
54
56
|
// a wholesale mock would green a webhook that taught it nothing.
|
|
@@ -57,7 +59,7 @@ import { recordDeliverabilityFromDeliveryEvents } from "@aglyn/tenant-data-admin
|
|
|
57
59
|
// renders them (`@aglyn/shared-ui-email-campaigns/model`) rather than here, so
|
|
58
60
|
// the shape the webhook writes and the shape the report reads cannot drift
|
|
59
61
|
// into two definitions of what a "link" is.
|
|
60
|
-
import {
|
|
62
|
+
import { SEND_LINK_ROLLUP_MAX, sendLinkKey } from "@aglyn/shared-ui-email-campaigns/model/send-report";
|
|
61
63
|
import { createHash } from "crypto";
|
|
62
64
|
import { FieldValue } from "firebase-admin/firestore";
|
|
63
65
|
import { assignExperimentVariant } from "../model/experiments.js";
|
|
@@ -263,7 +265,7 @@ import { suppressionId } from "./campaign-send.js";
|
|
|
263
265
|
// contain `.`, `/` or `~`, and every URL contains at least two of them.
|
|
264
266
|
// The URL itself rides in the value, so nothing has to be un-hashed.
|
|
265
267
|
const key = createHash('sha256').update(link).digest('hex').slice(0, 32);
|
|
266
|
-
if (links[key] === undefined && Object.keys(links).length >=
|
|
268
|
+
if (links[key] === undefined && Object.keys(links).length >= SEND_LINK_ROLLUP_MAX) {
|
|
267
269
|
transaction.set(ref, {
|
|
268
270
|
overflowClicks: FieldValue.increment(1)
|
|
269
271
|
}, {
|
|
@@ -658,7 +660,7 @@ import { suppressionId } from "./campaign-send.js";
|
|
|
658
660
|
* ONE DOCUMENT, not a document per URL. The report then reads the whole
|
|
659
661
|
* table with a single `getDoc`, and the map cannot grow without bound
|
|
660
662
|
* because the transaction refuses a new key past the cap and counts the
|
|
661
|
-
* click as overflow instead. `
|
|
663
|
+
* click as overflow instead. `sendLinkKey` drops the query string,
|
|
662
664
|
* so a link personalised per recipient cannot mint a row per recipient
|
|
663
665
|
* — see that function for why that is a correctness requirement and not
|
|
664
666
|
* only a size one.
|
|
@@ -675,7 +677,7 @@ import { suppressionId } from "./campaign-send.js";
|
|
|
675
677
|
await recordCampaignLinkClick({
|
|
676
678
|
firestore,
|
|
677
679
|
campaignRef,
|
|
678
|
-
link:
|
|
680
|
+
link: sendLinkKey(data == null ? void 0 : (_data_click = data.click) == null ? void 0 : _data_click.link)
|
|
679
681
|
}).catch(()=>undefined);
|
|
680
682
|
}
|
|
681
683
|
/*==========================================
|