@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.
Files changed (106) hide show
  1. package/README.md +4 -1
  2. package/package.json +15 -15
  3. package/src/lib/components/campaign-conversions-card.js +53 -64
  4. package/src/lib/components/campaign-conversions-card.js.map +1 -1
  5. package/src/lib/components/campaign-detail-card.js +2 -1
  6. package/src/lib/components/campaign-detail-card.js.map +1 -1
  7. package/src/lib/components/campaign-edit-drawer.js +1 -1
  8. package/src/lib/components/campaign-edit-drawer.js.map +1 -1
  9. package/src/lib/components/campaign-queries.js +1 -1
  10. package/src/lib/components/campaign-queries.js.map +1 -1
  11. package/src/lib/components/campaign-reach-sections.js +1 -1
  12. package/src/lib/components/campaign-reach-sections.js.map +1 -1
  13. package/src/lib/components/campaign-report-card.js +5 -4
  14. package/src/lib/components/campaign-report-card.js.map +1 -1
  15. package/src/lib/components/campaigns-card.js +1 -1
  16. package/src/lib/components/campaigns-card.js.map +1 -1
  17. package/src/lib/components/email-compose-card.js +1 -1
  18. package/src/lib/components/email-compose-card.js.map +1 -1
  19. package/src/lib/components/email-detail.js +5 -4
  20. package/src/lib/components/email-detail.js.map +1 -1
  21. package/src/lib/components/email-template-report-card.d.ts +53 -0
  22. package/src/lib/components/email-template-report-card.js +469 -0
  23. package/src/lib/components/email-template-report-card.js.map +1 -0
  24. package/src/lib/components/emails-list-card.js +2 -2
  25. package/src/lib/components/emails-list-card.js.map +1 -1
  26. package/src/lib/components/host-experiments-card.component.js +3 -2
  27. package/src/lib/components/host-experiments-card.component.js.map +1 -1
  28. package/src/lib/components/site-runtime.js +3 -1
  29. package/src/lib/components/site-runtime.js.map +1 -1
  30. package/src/lib/declarations.server.d.ts +9 -0
  31. package/src/lib/declarations.server.js +62 -0
  32. package/src/lib/declarations.server.js.map +1 -1
  33. package/src/lib/model/campaign-container.d.ts +503 -0
  34. package/src/lib/model/campaign-container.js +498 -0
  35. package/src/lib/model/campaign-container.js.map +1 -0
  36. package/src/lib/model/campaign-conversions.d.ts +8 -1
  37. package/src/lib/model/campaign-conversions.js +11 -0
  38. package/src/lib/model/campaign-conversions.js.map +1 -1
  39. package/src/lib/model/campaign-report.d.ts +158 -0
  40. package/src/lib/model/campaign-report.js +268 -0
  41. package/src/lib/model/campaign-report.js.map +1 -0
  42. package/src/lib/model/campaign-revenue.d.ts +3 -3
  43. package/src/lib/model/campaign-revenue.js +3 -3
  44. package/src/lib/model/campaign-revenue.js.map +1 -1
  45. package/src/lib/model/campaign-send-time.js +2 -2
  46. package/src/lib/model/campaign-send-time.js.map +1 -1
  47. package/src/lib/model/compile-client-automations.d.ts +2 -2
  48. package/src/lib/model/compile-client-automations.js +3 -3
  49. package/src/lib/model/compile-client-automations.js.map +1 -1
  50. package/src/lib/model/email-record.d.ts +175 -0
  51. package/src/lib/model/email-record.js +206 -0
  52. package/src/lib/model/email-record.js.map +1 -0
  53. package/src/lib/model/site-contract.d.ts +2 -2
  54. package/src/lib/model/site-contract.js.map +1 -1
  55. package/src/lib/model/template-report.d.ts +158 -0
  56. package/src/lib/model/template-report.js +249 -0
  57. package/src/lib/model/template-report.js.map +1 -0
  58. package/src/lib/plugin.js +16 -0
  59. package/src/lib/plugin.js.map +1 -1
  60. package/src/lib/preview-props.js.map +1 -1
  61. package/src/lib/server/campaign-attribution-store.d.ts +55 -0
  62. package/src/lib/server/campaign-attribution-store.js +98 -0
  63. package/src/lib/server/campaign-attribution-store.js.map +1 -0
  64. package/src/lib/server/campaign-conversion-attribution.d.ts +283 -0
  65. package/src/lib/server/campaign-conversion-attribution.js +447 -0
  66. package/src/lib/server/campaign-conversion-attribution.js.map +1 -0
  67. package/src/lib/server/campaign-manage.js +1 -1
  68. package/src/lib/server/campaign-manage.js.map +1 -1
  69. package/src/lib/server/campaign-org-refs.js +1 -1
  70. package/src/lib/server/campaign-org-refs.js.map +1 -1
  71. package/src/lib/server/campaign-recipients.js +7 -7
  72. package/src/lib/server/campaign-recipients.js.map +1 -1
  73. package/src/lib/server/campaign-send.js +25 -19
  74. package/src/lib/server/campaign-send.js.map +1 -1
  75. package/src/lib/server/conversion-creditor.d.ts +18 -0
  76. package/src/lib/server/conversion-creditor.js +131 -0
  77. package/src/lib/server/conversion-creditor.js.map +1 -0
  78. package/src/lib/server/email-campaign-touch.d.ts +81 -0
  79. package/src/lib/server/email-campaign-touch.js +227 -0
  80. package/src/lib/server/email-campaign-touch.js.map +1 -0
  81. package/src/lib/server/email-events.js +8 -6
  82. package/src/lib/server/email-events.js.map +1 -1
  83. package/src/lib/server/email-revenue-attribution.d.ts +186 -0
  84. package/src/lib/server/email-revenue-attribution.js +322 -0
  85. package/src/lib/server/email-revenue-attribution.js.map +1 -0
  86. package/src/lib/server/email-send-record-index.d.ts +34 -0
  87. package/src/lib/server/email-send-record-index.js +90 -0
  88. package/src/lib/server/email-send-record-index.js.map +1 -0
  89. package/src/lib/server/get-client-automations.js.map +1 -1
  90. package/src/lib/server/marketing-figures.js +3 -3
  91. package/src/lib/server/marketing-figures.js.map +1 -1
  92. package/src/lib/server/send-tally.d.ts +32 -0
  93. package/src/lib/server/send-tally.js +54 -0
  94. package/src/lib/server/send-tally.js.map +1 -0
  95. package/src/lib/testing/stand-in-person-records.d.ts +35 -0
  96. package/src/lib/testing/stand-in-person-records.js +67 -0
  97. package/src/lib/testing/stand-in-person-records.js.map +1 -0
  98. package/src/lib/testing/stand-in-record-counts.d.ts +26 -0
  99. package/src/lib/testing/stand-in-record-counts.js +70 -0
  100. package/src/lib/testing/stand-in-record-counts.js.map +1 -0
  101. package/src/lib/transfer/campaigns-package.d.ts +72 -0
  102. package/src/lib/transfer/campaigns-package.js +145 -0
  103. package/src/lib/transfer/campaigns-package.js.map +1 -0
  104. package/src/lib/transfer/campaigns-package.server.d.ts +33 -0
  105. package/src/lib/transfer/campaigns-package.server.js +305 -0
  106. 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 { recordEmailCampaignTouch, recordEmailDeliveryEvents, recordPersonEngagement } from "@aglyn/tenant-data-admin/server/email-delivery-log";
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 "@aglyn/tenant-data-admin/server/campaign-conversion-attribution";
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 { CAMPAIGN_LINK_ROLLUP_MAX, campaignLinkKey } from "@aglyn/shared-ui-email-campaigns/model";
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 >= CAMPAIGN_LINK_ROLLUP_MAX) {
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. `campaignLinkKey` drops the query string,
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: campaignLinkKey(data == null ? void 0 : (_data_click = data.click) == null ? void 0 : _data_click.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
  /*==========================================