@aglyn/plugins-marketing 1.0.0-beta.220 → 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,249 @@
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
+ */ /**
18
+ * ONE DESIGN, MEASURED ACROSS EVERY CAMPAIGN THAT USED IT.
19
+ *
20
+ * ## What this adds to `campaign-report.ts`
21
+ *
22
+ * A campaign report divides two numbers that were recorded by the same send.
23
+ * A design report divides two SUMS, and a sum has a membership: which
24
+ * campaigns went into it. That is the whole difficulty, and it is a new way
25
+ * for the repo's rule — every rate names its denominator — to be broken.
26
+ *
27
+ * ## The rule, restated for a sum
28
+ *
29
+ * **A rate's numerator and its denominator are summed over the SAME set of
30
+ * campaigns, and the screen is told how large that set is.**
31
+ *
32
+ * `delivered` is written only by the delivery webhook, so a design used by
33
+ * five campaigns may have three that recorded it and two that never did.
34
+ * Summing `uniqueOpens` over all five and dividing by `delivered` summed over
35
+ * three produces an open rate that can exceed 100% and is, in every case,
36
+ * the flattering substitution `campaign-report.ts` exists to refuse — carried
37
+ * out by addition instead of by picking the wrong field.
38
+ *
39
+ * So each rate here is built from a FILTERED subset, and
40
+ * {@link TemplateRate.denominatorLabel} names the subset as well as the
41
+ * quantity: `delivered across 3 of 5 campaigns`. A reader comparing this
42
+ * design's open rate with another's can see the two were taken over different
43
+ * populations without being told separately.
44
+ *
45
+ * ## Counts are over everything; rates are over what can be divided
46
+ *
47
+ * The COUNTS — opens, clicks, unsubscribes — are summed over every campaign,
48
+ * because a count is true whatever else was recorded. Only the rates narrow.
49
+ * The two are labelled differently on screen for that reason, and the caveats
50
+ * say how many campaigns each rate left out.
51
+ *
52
+ * ## Why this is a pure module
53
+ *
54
+ * Same reason as `campaign-report.ts`: a division written in JSX is a
55
+ * denominator chosen by whoever writes the next card. Here it is worse — the
56
+ * subset is invisible in the output unless something carries it — so the
57
+ * subset is data, computed once, and the screen renders it.
58
+ */ import { sendRate } from "@aglyn/shared-ui-email-campaigns/model/send-report";
59
+ import { emailAudienceLabel } from "./email-record.js";
60
+ /** A campaign that actually went out. The only kind engagement can describe. */ function wasSent(campaign) {
61
+ return campaign.sentAtMs !== null;
62
+ }
63
+ /** Sums one stats field over a set of campaigns, defaulting absent to zero. */ function total(campaigns, field) {
64
+ return campaigns.reduce((running, campaign)=>{
65
+ var _ref;
66
+ var _campaign_stats;
67
+ return running + Number((_ref = (_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats[field]) != null ? _ref : 0);
68
+ }, 0);
69
+ }
70
+ /**
71
+ * `${quantity} across N of M campaigns` — the denominator label for a sum.
72
+ *
73
+ * The `of M` half is what makes a narrowed rate readable. `delivered across 3
74
+ * campaigns` is true and still invites the reader to assume the design has
75
+ * three; `across 3 of 5` says on its face that two are missing from this
76
+ * number and not from the counts above it.
77
+ */ function acrossLabel(quantity, covered, all) {
78
+ return covered === all ? `${quantity} across ${covered} ${covered === 1 ? 'campaign' : 'campaigns'}` : `${quantity} across ${covered} of ${all} campaigns`;
79
+ }
80
+ /**
81
+ * Aggregates every campaign that used one design.
82
+ *
83
+ * @param campaigns every campaign document naming this design, sent or not.
84
+ * @param truncated the query that produced `campaigns` stopped at its read
85
+ * ceiling, so every total here is a FLOOR. Reported as a caveat rather
86
+ * than hidden, on the same reasoning as `audienceSizeTruncated`: a
87
+ * figure that is at least this large is useful, and a figure presented
88
+ * as complete when it is not is worse than none.
89
+ */ export function templateReport(campaigns, truncated = false) {
90
+ const sentCampaigns = campaigns.filter(wasSent);
91
+ const all = sentCampaigns.length;
92
+ /*
93
+ * THE SUBSETS. Each rate divides sums taken over one of these and nothing
94
+ * else, which is what makes the numerator and denominator describe the same
95
+ * population — see this module's header.
96
+ */ const withDelivery = sentCampaigns.filter((campaign)=>{
97
+ var _campaign_stats;
98
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.delivered) !== undefined;
99
+ });
100
+ const withUniqueOpens = withDelivery.filter((campaign)=>{
101
+ var _campaign_stats;
102
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.uniqueOpens) !== undefined;
103
+ });
104
+ /*
105
+ * Click rates need BOTH a delivery denominator and the record that the send
106
+ * carried an HTML part. A send with no HTML part reports zero clicks
107
+ * whatever recipients did, so including it in a click rate's denominator
108
+ * drags the rate down by however many untrackable sends this design has —
109
+ * a measurement of our own sending code presented as a measurement of the
110
+ * audience. `campaign-report.ts` refuses the same substitution for one
111
+ * campaign; a sum must refuse it per campaign, not for the design as a
112
+ * whole, or one old send would withhold the design's whole click rate.
113
+ */ const withClickTracking = withDelivery.filter((campaign)=>{
114
+ var _campaign_stats, _campaign_stats1;
115
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.clickTracked) === true && ((_campaign_stats1 = campaign.stats) == null ? void 0 : _campaign_stats1.uniqueClicks) !== undefined;
116
+ });
117
+ const withOpenersAndClicks = withClickTracking.filter((campaign)=>{
118
+ var _campaign_stats;
119
+ return ((_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.uniqueOpens) !== undefined;
120
+ });
121
+ const sent = total(sentCampaigns, 'sent');
122
+ const delivered = withDelivery.length ? total(withDelivery, 'delivered') : null;
123
+ const uniqueOpens = withUniqueOpens.length ? total(withUniqueOpens, 'uniqueOpens') : null;
124
+ const uniqueClicks = withClickTracking.length ? total(withClickTracking, 'uniqueClicks') : null;
125
+ const rates = {
126
+ /*
127
+ * Over the campaigns that recorded a delivery, and `sent` summed over
128
+ * those SAME campaigns rather than over all of them. The alternative —
129
+ * delivered over the design's whole `sent` — reads as a delivery failure
130
+ * proportional to how many of this design's sends predate the webhook.
131
+ */ delivery: delivered === null ? null : sendRate(delivered, total(withDelivery, 'sent'), acrossLabel('sent', withDelivery.length, all)),
132
+ open: sendRate(uniqueOpens != null ? uniqueOpens : undefined, withUniqueOpens.length ? total(withUniqueOpens, 'delivered') : undefined, acrossLabel('delivered', withUniqueOpens.length, all)),
133
+ click: sendRate(uniqueClicks != null ? uniqueClicks : undefined, withClickTracking.length ? total(withClickTracking, 'delivered') : undefined, acrossLabel('delivered', withClickTracking.length, all)),
134
+ clickToOpen: sendRate(withOpenersAndClicks.length ? total(withOpenersAndClicks, 'uniqueClicks') : undefined, withOpenersAndClicks.length ? total(withOpenersAndClicks, 'uniqueOpens') : undefined, acrossLabel('unique openers', withOpenersAndClicks.length, all)),
135
+ /*
136
+ * Bounces and their rate are over every sent campaign, because both
137
+ * halves are recorded for all of them: `sent` by the send itself and
138
+ * `bounced` by a webhook whose absence is a genuine zero — no bounce
139
+ * event means nothing bounced, unlike `delivered`, whose absence means
140
+ * nothing was measured.
141
+ */ bounce: sendRate(total(sentCampaigns, 'bounced'), sent, acrossLabel('sent', all, all)),
142
+ complaint: sendRate(withDelivery.length ? total(withDelivery, 'complained') : undefined, delivered != null ? delivered : undefined, acrossLabel('delivered', withDelivery.length, all)),
143
+ unsubscribe: sendRate(withDelivery.length ? total(withDelivery, 'unsubscribes') : undefined, delivered != null ? delivered : undefined, acrossLabel('delivered', withDelivery.length, all))
144
+ };
145
+ const caveats = [];
146
+ if (!all) {
147
+ caveats.push({
148
+ id: 'no-campaigns',
149
+ message: campaigns.length ? 'No campaign using this design has been sent yet, so there is ' + 'nothing to measure. The campaigns below are scheduled or were ' + 'canceled before they went out.' : 'This design has never been sent, so there is nothing to measure.'
150
+ });
151
+ } else if (withDelivery.length < all) {
152
+ caveats.push({
153
+ id: 'delivery-partial',
154
+ message: `${all - withDelivery.length} of ${all} campaigns using this design ` + 'recorded no delivery events, so they are left out of every rate ' + 'taken over delivered — open, click, complaint and unsubscribe. ' + 'Their counts are still included above.'
155
+ });
156
+ }
157
+ if (all && withClickTracking.length < withDelivery.length) {
158
+ caveats.push({
159
+ id: 'click-tracking-partial',
160
+ message: `${withDelivery.length - withClickTracking.length} of these campaigns ` + 'did not record carrying an HTML part. Click tracking rewrites links ' + 'in the HTML, so those sends report zero clicks whatever recipients ' + 'did, and they are left out of the click rates rather than counted ' + 'as sends nobody clicked.'
161
+ });
162
+ }
163
+ if (truncated) {
164
+ caveats.push({
165
+ id: 'campaigns-truncated',
166
+ message: 'This design has been used by more campaigns than one read returns, ' + 'so every total here is a floor — the real figures are at least ' + 'this large.'
167
+ });
168
+ }
169
+ if (sentCampaigns.some((campaign)=>{
170
+ var _campaign_stats;
171
+ return (_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.audienceSizeTruncated;
172
+ })) {
173
+ caveats.push({
174
+ id: 'audience-truncated',
175
+ message: 'At least one of these sends stopped audience resolution at its read ' + 'ceiling, so the audience figures it contributed are floors.'
176
+ });
177
+ }
178
+ return {
179
+ sentCampaigns: all,
180
+ totalCampaigns: campaigns.length,
181
+ lastSentAtMs: sentCampaigns.reduce((latest, campaign)=>{
182
+ var _campaign_sentAtMs;
183
+ return latest === null || ((_campaign_sentAtMs = campaign.sentAtMs) != null ? _campaign_sentAtMs : 0) > latest ? campaign.sentAtMs : latest;
184
+ }, null),
185
+ recipients: total(sentCampaigns, 'recipients'),
186
+ sent,
187
+ delivered,
188
+ opens: total(sentCampaigns, 'opens'),
189
+ clicks: total(sentCampaigns, 'clicks'),
190
+ uniqueOpens,
191
+ uniqueClicks,
192
+ bounced: total(sentCampaigns, 'bounced'),
193
+ complained: total(sentCampaigns, 'complained'),
194
+ unsubscribes: total(sentCampaigns, 'unsubscribes'),
195
+ rates,
196
+ audiences: templateAudiences(sentCampaigns),
197
+ caveats
198
+ };
199
+ }
200
+ /**
201
+ * The audiences the design actually went to, largest first.
202
+ *
203
+ * Keyed on the LIST ID for a list send and on the audience kind otherwise, so
204
+ * two campaigns to the same list are one row and two campaigns to "all leads"
205
+ * are one row — but a list send and a segment send are never merged, because
206
+ * they are different questions about who received this design.
207
+ *
208
+ * A list send whose campaign recorded no list id at all is dropped rather
209
+ * than filed under a generic "List" heading: a row that cannot name which
210
+ * list is a row that answers the question wrongly.
211
+ */ function templateAudiences(campaigns) {
212
+ const rows = new Map();
213
+ for (const campaign of campaigns){
214
+ var _ref;
215
+ var _campaign_stats;
216
+ const isList = campaign.audience === 'list';
217
+ if (isList && !campaign.listId) continue;
218
+ const id = isList ? `list:${campaign.listId}` : campaign.audience;
219
+ const existing = rows.get(id);
220
+ const addressed = Number((_ref = (_campaign_stats = campaign.stats) == null ? void 0 : _campaign_stats.recipients) != null ? _ref : 0);
221
+ if (existing) {
222
+ existing.campaigns += 1;
223
+ existing.addressed += addressed;
224
+ // A later send that DID record the name fills one that did not, so a
225
+ // list mailed twice is named whenever any of those sends named it.
226
+ if (existing.unnamed && campaign.listName) {
227
+ existing.label = campaign.listName;
228
+ delete existing.unnamed;
229
+ }
230
+ continue;
231
+ }
232
+ rows.set(id, _extends({
233
+ id,
234
+ // Named once, in `email-record.ts`, so a list row on this table and
235
+ // the same list on a message's own page cannot disagree about what to
236
+ // call it.
237
+ label: emailAudienceLabel(campaign),
238
+ campaigns: 1,
239
+ addressed
240
+ }, isList && !campaign.listName ? {
241
+ unnamed: true
242
+ } : {}));
243
+ }
244
+ return [
245
+ ...rows.values()
246
+ ].sort((a, b)=>b.addressed - a.addressed || a.label.localeCompare(b.label));
247
+ }
248
+
249
+ //# sourceMappingURL=template-report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/model/template-report.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\n/**\n * ONE DESIGN, MEASURED ACROSS EVERY CAMPAIGN THAT USED IT.\n *\n * ## What this adds to `campaign-report.ts`\n *\n * A campaign report divides two numbers that were recorded by the same send.\n * A design report divides two SUMS, and a sum has a membership: which\n * campaigns went into it. That is the whole difficulty, and it is a new way\n * for the repo's rule — every rate names its denominator — to be broken.\n *\n * ## The rule, restated for a sum\n *\n * **A rate's numerator and its denominator are summed over the SAME set of\n * campaigns, and the screen is told how large that set is.**\n *\n * `delivered` is written only by the delivery webhook, so a design used by\n * five campaigns may have three that recorded it and two that never did.\n * Summing `uniqueOpens` over all five and dividing by `delivered` summed over\n * three produces an open rate that can exceed 100% and is, in every case,\n * the flattering substitution `campaign-report.ts` exists to refuse — carried\n * out by addition instead of by picking the wrong field.\n *\n * So each rate here is built from a FILTERED subset, and\n * {@link TemplateRate.denominatorLabel} names the subset as well as the\n * quantity: `delivered across 3 of 5 campaigns`. A reader comparing this\n * design's open rate with another's can see the two were taken over different\n * populations without being told separately.\n *\n * ## Counts are over everything; rates are over what can be divided\n *\n * The COUNTS — opens, clicks, unsubscribes — are summed over every campaign,\n * because a count is true whatever else was recorded. Only the rates narrow.\n * The two are labelled differently on screen for that reason, and the caveats\n * say how many campaigns each rate left out.\n *\n * ## Why this is a pure module\n *\n * Same reason as `campaign-report.ts`: a division written in JSX is a\n * denominator chosen by whoever writes the next card. Here it is worse — the\n * subset is invisible in the output unless something carries it — so the\n * subset is data, computed once, and the screen renders it.\n */\n\nimport {\n sendRate,\n type SendRate,\n type SendStats,\n} from '@aglyn/shared-ui-email-campaigns/model/send-report'\nimport { emailAudienceLabel } from './email-record'\n\n/** A rate over a sum, carrying how many campaigns the sum covers. */\nexport type TemplateRate = SendRate\n\n/**\n * One campaign that used the design, as the report reads it.\n *\n * `sentAtMs` is nullable because a campaign document can exist without ever\n * having been sent — a scheduled campaign holds `sendAtMs` and no `sentAt`.\n * Those carry no engagement and the reader is shown their status rather than\n * a row of zeroes.\n */\nexport interface TemplateCampaign {\n campaignId: string\n subject: string\n /** Epoch ms, or null for a campaign that has not been sent. */\n sentAtMs: number | null\n /** `'sent'`, `'scheduled'`, `'canceled'`. A persisted value; not relabeled. */\n status: string\n /** The audience KIND — `'leads'`, `'members'`, `'segment'`, `'list'`. */\n audience: string\n /** Set when `audience` is `'list'`. */\n listId?: string\n /** The list's name AS IT WAS at send time, when the send recorded one. */\n listName?: string\n stats: SendStats | undefined\n}\n\n/**\n * One audience the design was sent to.\n *\n * A LIST is named by the name the send recorded, not by the name the list\n * carries today: a renamed or deleted list would otherwise rewrite the\n * history of a campaign that went out months ago, and a deleted one would\n * erase it. The same reasoning as the campaign report's populations.\n */\nexport interface TemplateAudience {\n /** Stable key — the list id, or the audience kind for the built-ins. */\n id: string\n label: string\n /** How many campaigns using this design went to this audience. */\n campaigns: number\n /** Addresses those campaigns ADDRESSED, summed. */\n addressed: number\n /**\n * The named list has since been deleted or renamed out of reach.\n *\n * Only ever true for a `list` audience, and only when the send recorded no\n * name — an older send, before the name was stored. The screen says \"list\n * no longer named\" rather than printing a raw document id as if it were\n * something a merchant would recognise.\n */\n unnamed?: boolean\n}\n\n/** Why a number the design report would otherwise show is being withheld. */\nexport interface TemplateCaveat {\n id:\n | 'no-campaigns'\n | 'delivery-partial'\n | 'click-tracking-partial'\n | 'campaigns-truncated'\n | 'audience-truncated'\n message: string\n}\n\n/** Everything the design report screen renders. */\nexport interface TemplateReport {\n /** Campaigns that used this design AND were sent. */\n sentCampaigns: number\n /** Campaigns that used this design, sent or not. */\n totalCampaigns: number\n /** Epoch ms of the most recent send, or null. */\n lastSentAtMs: number | null\n\n /*========================================\n * COUNTS — over every sent campaign.\n *=======================================*/\n recipients: number\n sent: number\n /** Summed over the campaigns that recorded it; null when none did. */\n delivered: number | null\n opens: number\n clicks: number\n uniqueOpens: number | null\n uniqueClicks: number | null\n bounced: number\n complained: number\n unsubscribes: number\n\n /** Rates, each `null` when its denominator is zero or unrecorded. */\n rates: {\n delivery: TemplateRate | null\n open: TemplateRate | null\n click: TemplateRate | null\n clickToOpen: TemplateRate | null\n bounce: TemplateRate | null\n complaint: TemplateRate | null\n unsubscribe: TemplateRate | null\n }\n\n audiences: TemplateAudience[]\n caveats: TemplateCaveat[]\n}\n\n/** A campaign that actually went out. The only kind engagement can describe. */\nfunction wasSent(campaign: TemplateCampaign): boolean {\n return campaign.sentAtMs !== null\n}\n\n/** Sums one stats field over a set of campaigns, defaulting absent to zero. */\nfunction total(\n campaigns: readonly TemplateCampaign[],\n field: keyof SendStats,\n): number {\n return campaigns.reduce(\n (running, campaign) => running + Number(campaign.stats?.[field] ?? 0),\n 0,\n )\n}\n\n/**\n * `${quantity} across N of M campaigns` — the denominator label for a sum.\n *\n * The `of M` half is what makes a narrowed rate readable. `delivered across 3\n * campaigns` is true and still invites the reader to assume the design has\n * three; `across 3 of 5` says on its face that two are missing from this\n * number and not from the counts above it.\n */\nfunction acrossLabel(quantity: string, covered: number, all: number): string {\n return covered === all\n ? `${quantity} across ${covered} ${covered === 1 ? 'campaign' : 'campaigns'}`\n : `${quantity} across ${covered} of ${all} campaigns`\n}\n\n/**\n * Aggregates every campaign that used one design.\n *\n * @param campaigns every campaign document naming this design, sent or not.\n * @param truncated the query that produced `campaigns` stopped at its read\n * ceiling, so every total here is a FLOOR. Reported as a caveat rather\n * than hidden, on the same reasoning as `audienceSizeTruncated`: a\n * figure that is at least this large is useful, and a figure presented\n * as complete when it is not is worse than none.\n */\nexport function templateReport(\n campaigns: readonly TemplateCampaign[],\n truncated = false,\n): TemplateReport {\n const sentCampaigns = campaigns.filter(wasSent)\n const all = sentCampaigns.length\n\n /*\n * THE SUBSETS. Each rate divides sums taken over one of these and nothing\n * else, which is what makes the numerator and denominator describe the same\n * population — see this module's header.\n */\n const withDelivery = sentCampaigns.filter(\n (campaign) => campaign.stats?.delivered !== undefined,\n )\n const withUniqueOpens = withDelivery.filter(\n (campaign) => campaign.stats?.uniqueOpens !== undefined,\n )\n /*\n * Click rates need BOTH a delivery denominator and the record that the send\n * carried an HTML part. A send with no HTML part reports zero clicks\n * whatever recipients did, so including it in a click rate's denominator\n * drags the rate down by however many untrackable sends this design has —\n * a measurement of our own sending code presented as a measurement of the\n * audience. `campaign-report.ts` refuses the same substitution for one\n * campaign; a sum must refuse it per campaign, not for the design as a\n * whole, or one old send would withhold the design's whole click rate.\n */\n const withClickTracking = withDelivery.filter(\n (campaign) =>\n campaign.stats?.clickTracked === true &&\n campaign.stats?.uniqueClicks !== undefined,\n )\n const withOpenersAndClicks = withClickTracking.filter(\n (campaign) => campaign.stats?.uniqueOpens !== undefined,\n )\n\n const sent = total(sentCampaigns, 'sent')\n const delivered = withDelivery.length\n ? total(withDelivery, 'delivered')\n : null\n const uniqueOpens = withUniqueOpens.length\n ? total(withUniqueOpens, 'uniqueOpens')\n : null\n const uniqueClicks = withClickTracking.length\n ? total(withClickTracking, 'uniqueClicks')\n : null\n\n const rates: TemplateReport['rates'] = {\n /*\n * Over the campaigns that recorded a delivery, and `sent` summed over\n * those SAME campaigns rather than over all of them. The alternative —\n * delivered over the design's whole `sent` — reads as a delivery failure\n * proportional to how many of this design's sends predate the webhook.\n */\n delivery: delivered === null\n ? null\n : sendRate(\n delivered,\n total(withDelivery, 'sent'),\n acrossLabel('sent', withDelivery.length, all),\n ),\n open: sendRate(\n uniqueOpens ?? undefined,\n withUniqueOpens.length ? total(withUniqueOpens, 'delivered') : undefined,\n acrossLabel('delivered', withUniqueOpens.length, all),\n ),\n click: sendRate(\n uniqueClicks ?? undefined,\n withClickTracking.length\n ? total(withClickTracking, 'delivered')\n : undefined,\n acrossLabel('delivered', withClickTracking.length, all),\n ),\n clickToOpen: sendRate(\n withOpenersAndClicks.length\n ? total(withOpenersAndClicks, 'uniqueClicks')\n : undefined,\n withOpenersAndClicks.length\n ? total(withOpenersAndClicks, 'uniqueOpens')\n : undefined,\n acrossLabel('unique openers', withOpenersAndClicks.length, all),\n ),\n /*\n * Bounces and their rate are over every sent campaign, because both\n * halves are recorded for all of them: `sent` by the send itself and\n * `bounced` by a webhook whose absence is a genuine zero — no bounce\n * event means nothing bounced, unlike `delivered`, whose absence means\n * nothing was measured.\n */\n bounce: sendRate(\n total(sentCampaigns, 'bounced'),\n sent,\n acrossLabel('sent', all, all),\n ),\n complaint: sendRate(\n withDelivery.length ? total(withDelivery, 'complained') : undefined,\n delivered ?? undefined,\n acrossLabel('delivered', withDelivery.length, all),\n ),\n unsubscribe: sendRate(\n withDelivery.length ? total(withDelivery, 'unsubscribes') : undefined,\n delivered ?? undefined,\n acrossLabel('delivered', withDelivery.length, all),\n ),\n }\n\n const caveats: TemplateCaveat[] = []\n if (!all) {\n caveats.push({\n id: 'no-campaigns',\n message: campaigns.length\n ? 'No campaign using this design has been sent yet, so there is ' +\n 'nothing to measure. The campaigns below are scheduled or were ' +\n 'canceled before they went out.'\n : 'This design has never been sent, so there is nothing to measure.',\n })\n } else if (withDelivery.length < all) {\n caveats.push({\n id: 'delivery-partial',\n message:\n `${all - withDelivery.length} of ${all} campaigns using this design ` +\n 'recorded no delivery events, so they are left out of every rate ' +\n 'taken over delivered — open, click, complaint and unsubscribe. ' +\n 'Their counts are still included above.',\n })\n }\n if (all && withClickTracking.length < withDelivery.length) {\n caveats.push({\n id: 'click-tracking-partial',\n message:\n `${withDelivery.length - withClickTracking.length} of these campaigns ` +\n 'did not record carrying an HTML part. Click tracking rewrites links ' +\n 'in the HTML, so those sends report zero clicks whatever recipients ' +\n 'did, and they are left out of the click rates rather than counted ' +\n 'as sends nobody clicked.',\n })\n }\n if (truncated) {\n caveats.push({\n id: 'campaigns-truncated',\n message:\n 'This design has been used by more campaigns than one read returns, ' +\n 'so every total here is a floor — the real figures are at least ' +\n 'this large.',\n })\n }\n if (sentCampaigns.some((campaign) => campaign.stats?.audienceSizeTruncated)) {\n caveats.push({\n id: 'audience-truncated',\n message:\n 'At least one of these sends stopped audience resolution at its read ' +\n 'ceiling, so the audience figures it contributed are floors.',\n })\n }\n\n return {\n sentCampaigns: all,\n totalCampaigns: campaigns.length,\n lastSentAtMs: sentCampaigns.reduce<number | null>(\n (latest, campaign) =>\n latest === null || (campaign.sentAtMs ?? 0) > latest\n ? campaign.sentAtMs\n : latest,\n null,\n ),\n recipients: total(sentCampaigns, 'recipients'),\n sent,\n delivered,\n opens: total(sentCampaigns, 'opens'),\n clicks: total(sentCampaigns, 'clicks'),\n uniqueOpens,\n uniqueClicks,\n bounced: total(sentCampaigns, 'bounced'),\n complained: total(sentCampaigns, 'complained'),\n unsubscribes: total(sentCampaigns, 'unsubscribes'),\n rates,\n audiences: templateAudiences(sentCampaigns),\n caveats,\n }\n}\n\n/**\n * The audiences the design actually went to, largest first.\n *\n * Keyed on the LIST ID for a list send and on the audience kind otherwise, so\n * two campaigns to the same list are one row and two campaigns to \"all leads\"\n * are one row — but a list send and a segment send are never merged, because\n * they are different questions about who received this design.\n *\n * A list send whose campaign recorded no list id at all is dropped rather\n * than filed under a generic \"List\" heading: a row that cannot name which\n * list is a row that answers the question wrongly.\n */\nfunction templateAudiences(\n campaigns: readonly TemplateCampaign[],\n): TemplateAudience[] {\n const rows = new Map<string, TemplateAudience>()\n for (const campaign of campaigns) {\n const isList = campaign.audience === 'list'\n if (isList && !campaign.listId) continue\n const id = isList ? `list:${campaign.listId}` : campaign.audience\n const existing = rows.get(id)\n const addressed = Number(campaign.stats?.recipients ?? 0)\n if (existing) {\n existing.campaigns += 1\n existing.addressed += addressed\n // A later send that DID record the name fills one that did not, so a\n // list mailed twice is named whenever any of those sends named it.\n if (existing.unnamed && campaign.listName) {\n existing.label = campaign.listName\n delete existing.unnamed\n }\n continue\n }\n rows.set(id, {\n id,\n // Named once, in `email-record.ts`, so a list row on this table and\n // the same list on a message's own page cannot disagree about what to\n // call it.\n label: emailAudienceLabel(campaign),\n campaigns: 1,\n addressed,\n ...(isList && !campaign.listName ? { unnamed: true as const } : {}),\n })\n }\n return [...rows.values()].sort(\n (a, b) => b.addressed - a.addressed || a.label.localeCompare(b.label),\n )\n}\n"],"names":["sendRate","emailAudienceLabel","wasSent","campaign","sentAtMs","total","campaigns","field","reduce","running","Number","stats","acrossLabel","quantity","covered","all","templateReport","truncated","sentCampaigns","filter","length","withDelivery","delivered","undefined","withUniqueOpens","uniqueOpens","withClickTracking","clickTracked","uniqueClicks","withOpenersAndClicks","sent","rates","delivery","open","click","clickToOpen","bounce","complaint","unsubscribe","caveats","push","id","message","some","audienceSizeTruncated","totalCampaigns","lastSentAtMs","latest","recipients","opens","clicks","bounced","complained","unsubscribes","audiences","templateAudiences","rows","Map","isList","audience","listId","existing","get","addressed","unnamed","listName","label","set","values","sort","a","b","localeCompare"],"mappings":"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"}
package/src/lib/plugin.js CHANGED
@@ -20,6 +20,7 @@ import { lazy } from "react";
20
20
  import { CAMPAIGN_DESIGN_CREATE_ZONE, CAMPAIGN_DESIGN_PREVIEW_ZONE, CAMPAIGN_SENDER_EDITOR_ZONE, CAMPAIGN_TOPIC_OPTIONS_ZONE, CAMPAIGN_TOPIC_SELECT_ZONE } from "./components/campaign-email-zones.js";
21
21
  import { EXPERIMENT_RESULT_ZONE, EXPERIMENT_VARIANTS_ZONE } from "./components/experiment-zones.js";
22
22
  import { MARKETING_CONSOLE_SECTIONS, MARKETING_ORG_CONSOLE_SECTIONS } from "./components/marketing-console-sections.js";
23
+ import { registerPluginTransferResourceUi } from "@aglyn/aglyn/plugin-manager/plugin-transfer-resources";
23
24
  import { BUNDLE_ID } from "./constants/bundle-common.js";
24
25
  import { StaffOrgEmailCard } from "./components/staff-org-email-card.component.js";
25
26
  import { registerMarketingRecordLists } from "./model/overlay-record-list.js";
@@ -31,6 +32,7 @@ import { registerMarketingRecordRoutes } from "./model/marketing-record-routes.j
31
32
  * site's on the organization's Inbox.
32
33
  */ const InboxCampaignsWidget = lazy(()=>import("./components/inbox-campaigns-widget.js"));
33
34
  /** The Emails page's Messages section: the list, one message, its composer. */ const EmailMessagesWidget = lazy(()=>import("./components/email-messages-widget.js"));
35
+ /** What the sends built from one template did, on the template's page. */ const EmailTemplateReportCard = lazy(()=>import("./components/email-template-report-card.js"));
34
36
  /** Who received the sends built from one template, on the template's page. */ const EmailRecipientsCard = lazy(()=>import("./components/email-recipients-card.js"));
35
37
  /** Loaded where a record page draws one of the attribution zones. */ const RecordAttributionWidget = lazy(()=>import("./components/record-attribution-widget.js"));
36
38
  /** The site's section experiments, reported to the besigner's Interactions section. */ const BesignerSectionExperiments = lazy(()=>import("./components/besigner-section-experiments.js"));
@@ -43,6 +45,12 @@ import { registerMarketingRecordRoutes } from "./model/marketing-record-routes.j
43
45
  * their own per-plan checks off the passed `org`). The popup image picker
44
46
  * uses the shell's media browser via `useMediaPicker`.
45
47
  */ export function registerMarketingConsole() {
48
+ // How the Import & export hub names campaigns in a workspace package (AGL-3535).
49
+ registerPluginTransferResourceUi('marketing.campaigns', {
50
+ label: 'Campaigns'
51
+ }, {
52
+ pluginId: BUNDLE_ID
53
+ });
46
54
  /*
47
55
  * The two positions the A/B testing card hosts (AGL-2914), declared before
48
56
  * the extension that draws the card. An id another plugin has already taken
@@ -128,6 +136,14 @@ import { registerMarketingRecordRoutes } from "./model/marketing-record-routes.j
128
136
  title: 'Messages',
129
137
  Component: EmailMessagesWidget
130
138
  },
139
+ // A template's page: what the sends built from it did, then who
140
+ // received them. The template is the Email plugin's; the sends are ours.
141
+ {
142
+ slot: 'emailTemplateReport',
143
+ widgetId: 'marketing-email-template-report',
144
+ title: 'Sent from this template',
145
+ Component: EmailTemplateReportCard
146
+ },
131
147
  {
132
148
  slot: 'emailTemplateRecipients',
133
149
  widgetId: 'marketing-email-template-recipients',
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/marketing/src/lib/plugin.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 * as Aglyn from '@aglyn/aglyn'\nimport {\n registerPluginZone,\n type PluginZone,\n} from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { mdiBullhornOutline } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n CAMPAIGN_DESIGN_CREATE_ZONE,\n CAMPAIGN_DESIGN_PREVIEW_ZONE,\n CAMPAIGN_SENDER_EDITOR_ZONE,\n CAMPAIGN_TOPIC_OPTIONS_ZONE,\n CAMPAIGN_TOPIC_SELECT_ZONE,\n} from './components/campaign-email-zones'\nimport {\n EXPERIMENT_RESULT_ZONE,\n EXPERIMENT_VARIANTS_ZONE,\n} from './components/experiment-zones'\nimport {\n MARKETING_CONSOLE_SECTIONS,\n MARKETING_ORG_CONSOLE_SECTIONS,\n} from './components/marketing-console-sections'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { StaffOrgEmailCard } from './components/staff-org-email-card.component'\nimport { registerMarketingRecordLists } from './model/overlay-record-list'\nimport { registerMarketingRecordRoutes } from './model/marketing-record-routes'\n\n/** Code-split: the Marketing console page only loads when opened. */\nconst MarketingConsolePage = lazy(\n () => import('./components/marketing-console-page'),\n)\n\n/** Dashboard glance card, loaded only where the shell renders the slot. */\nconst CampaignGlanceCard = lazy(\n () => import('./components/campaign-glance-card.component'),\n)\n\n/**\n * The campaigns drawn in the Inbox's Campaigns section: one site's, or every\n * site's on the organization's Inbox.\n */\nconst InboxCampaignsWidget = lazy(\n () => import('./components/inbox-campaigns-widget'),\n)\n\n/** The Emails page's Messages section: the list, one message, its composer. */\nconst EmailMessagesWidget = lazy(\n () => import('./components/email-messages-widget'),\n)\n\n/** Who received the sends built from one template, on the template's page. */\nconst EmailRecipientsCard = lazy(\n () => import('./components/email-recipients-card'),\n)\n\n/** Loaded where a record page draws one of the attribution zones. */\nconst RecordAttributionWidget = lazy(\n () => import('./components/record-attribution-widget'),\n)\n\n/** The site's section experiments, reported to the besigner's Interactions section. */\nconst BesignerSectionExperiments = lazy(\n () => import('./components/besigner-section-experiments'),\n)\n\n/**\n * Marketing feature plugin (AGL-395). Console-only — overlays and popups\n * render on published sites through the tenant runtime, not a canvas\n * element of their own, so there is no UI bundle. The console half declares\n * the Marketing nav + page through the ConsoleExtension registry (always-on;\n * the surface itself is not release-flagged — its overlays/A-B cards run\n * their own per-plan checks off the passed `org`). The popup image picker\n * uses the shell's media browser via `useMediaPicker`.\n */\nexport function registerMarketingConsole(): void {\n /*\n * The two positions the A/B testing card hosts (AGL-2914), declared before\n * the extension that draws the card. An id another plugin has already taken\n * is refused, naming both. What each zone hands a widget is carried on its\n * token, in `components/experiment-zones`, so a widget elsewhere is written\n * against the same shape without importing this plugin.\n */\n registerPluginZone(\n {\n zone: EXPERIMENT_VARIANTS_ZONE,\n label: 'A/B test variants',\n surface: 'console',\n description:\n 'In the experiment editor, beside the variants. A widget here proposes copy for the variants the editor holds; the dialog’s Save is the write, and nothing a widget returns starts or changes a test.',\n },\n // Named rather than left to the loader's marker, because this function is\n // also called directly — by a spec, and by an app that loads the plugin\n // without the loader — and a zone with no owner is refused.\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: EXPERIMENT_RESULT_ZONE,\n label: 'A/B test result',\n surface: 'console',\n description:\n 'Below the results of one test. A widget here explains what the figures show. There is nothing to apply, and a widget may not declare a winner the card has not.',\n },\n { pluginId: BUNDLE_ID },\n )\n /*\n * What a campaign email asks of whichever plugin keeps the mail itself —\n * the topic catalog, the sending identities, the design document and its\n * renderer. Each is a zone the composer or a message's page hosts; see\n * `components/campaign-email-zones` for what each hands a widget. All are\n * `bare`: a widget there is a field, a button, a drawer or nothing at all,\n * and the stack's spacing around it would be a gap in a form.\n */\n const bareZone = <Props,>(\n zone: PluginZone<Props>,\n label: string,\n description: string,\n ) =>\n registerPluginZone(\n { zone, label, description, surface: 'console', layout: 'bare' },\n { pluginId: BUNDLE_ID },\n )\n bareZone(\n CAMPAIGN_TOPIC_SELECT_ZONE,\n 'An email’s topic',\n 'In the campaign composer’s field stack. A widget here is the picker for the stream the email belongs to: it is handed the chosen id and reports a new one through `onChange`. The composer records it; the widget writes nothing.',\n )\n bareZone(\n CAMPAIGN_TOPIC_OPTIONS_ZONE,\n 'The topics a campaign can open on',\n 'Beside the drawers that create and edit a campaign. A widget here draws nothing: while `enabled` it reads the streams a recipient can still leave and answers through `onTopics`.',\n )\n bareZone(\n CAMPAIGN_SENDER_EDITOR_ZONE,\n 'Add a sender while composing',\n 'Mounted by the campaign composer once its author asks to add a sender. A widget here is that editor, open in its add mode; `onSaved` names the sender it created.',\n )\n bareZone(\n CAMPAIGN_DESIGN_CREATE_ZONE,\n 'A design for one email',\n 'In the campaign composer, beside the design picker. A widget here is the control that mints a design document and names it through `onCreated`; the composer records the choice on the campaign and opens the editor.',\n )\n bareZone(\n CAMPAIGN_DESIGN_PREVIEW_ZONE,\n 'A sent email as an inbox receives it',\n 'On one message’s page. A widget here renders the stored design, or the plain-text body, through the renderer the send path uses.',\n )\n registerMarketingRecordRoutes()\n // The site's overlays, for another plugin's picker to list (AGL-3080).\n registerMarketingRecordLists()\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Marketing',\n /*\n * The host dashboard's `Last campaign` card (AGL-433). A widget rather\n * than a direct import by the dashboard page, so it appears only on\n * workspaces that have the plugin that owns the campaign history — a card\n * about campaigns on a console with no campaigns page could only ever be\n * blank, and its header links straight into `/marketing/campaigns`.\n */\n widgets: [\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.hostDashboard,\n // The id a reader's dashboard preferences already name — it is what\n // `isDashboardWidgetHidden` and the customize order are keyed on, so\n // it is persisted and does not follow the plugin.\n widgetId: 'email-campaign-glance',\n title: 'Last campaign',\n Component: CampaignGlanceCard,\n },\n // Which campaign or link brought a person, drawn where the plugins that\n // keep and list people host a zone for it. They hand over what the\n // record is and its id; neither imports this plugin.\n {\n slot: 'crmRecordAttribution',\n widgetId: 'marketing-crm-record-attribution',\n title: 'Campaign attribution',\n Component: RecordAttributionWidget,\n },\n // The Emails page hosts its Messages section as a zone: a message is\n // one send of a campaign, and every action on it is this plugin's route.\n {\n slot: 'emailMessages',\n widgetId: 'marketing-email-messages',\n title: 'Messages',\n Component: EmailMessagesWidget,\n },\n {\n slot: 'emailTemplateRecipients',\n widgetId: 'marketing-email-template-recipients',\n title: 'Recipients',\n Component: EmailRecipientsCard,\n },\n // The Inbox's Campaigns tab is a zone; this is the card that fills it.\n {\n slot: 'inboxCampaigns',\n widgetId: 'marketing-inbox-campaigns',\n title: 'Campaigns',\n Component: InboxCampaignsWidget,\n },\n {\n slot: 'inboxRecordAttribution',\n widgetId: 'marketing-inbox-record-attribution',\n title: 'Campaign attribution',\n Component: RecordAttributionWidget,\n },\n // The section experiments the besigner's Interactions section badges an\n // element with and starts on a page's element; reported, never drawn.\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.besignerInteractions,\n widgetId: 'marketing-besigner-section-experiments',\n title: 'Section experiments',\n Component: BesignerSectionExperiments,\n },\n // The staff organization page's sends and campaigns (AGL-3380), read\n // only: staff see what the organization sent without joining it.\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.adminOrgDetail,\n widgetId: 'marketing-staff-org-email',\n title: 'Email campaigns',\n Component: StaffOrgEmailCard,\n },\n ],\n navItems: [\n {\n label: 'Marketing',\n href: '/marketing',\n // Sections as ROUTES (AGL-2501): each is a real URL the shell\n // resolves and gates, so the page mounts the one being read.\n sections: MARKETING_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-marketing',\n icon: { path: mdiBullhornOutline.path },\n header: {\n title: 'Marketing',\n icon: { path: mdiBullhornOutline.path },\n docsTopic: 'marketingOverlays',\n },\n Component: MarketingConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Marketing hub, at `/[orgSlug]/marketing`: every\n * section the site hub has, over every site. Campaigns and their sequence\n * rollups belong to the organization, so this is where one campaign is\n * placed across several sites; overlays, tests and conversions stay each\n * site's and are read across them. Every site's mail is on the\n * organization's Emails page, as a site's is on its own. The page is the\n * same component; handed no site, it renders the org sections.\n *\n * It carries the SITE tab's id on purpose, the way the org CRM tab does:\n * `release_marketing` names `nav-tab-marketing`, so one flag holds both\n * halves of the surface, and the org half cannot ship while the site half\n * is switched off.\n */\n orgNavItems: [\n {\n label: 'Marketing',\n href: '/marketing',\n sections: MARKETING_ORG_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-marketing',\n icon: { path: mdiBullhornOutline.path },\n header: {\n title: 'Marketing',\n icon: { path: mdiBullhornOutline.path },\n docsTopic: 'marketingOverlays',\n },\n Component: MarketingConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginZone","mdiBullhornOutline","lazy","CAMPAIGN_DESIGN_CREATE_ZONE","CAMPAIGN_DESIGN_PREVIEW_ZONE","CAMPAIGN_SENDER_EDITOR_ZONE","CAMPAIGN_TOPIC_OPTIONS_ZONE","CAMPAIGN_TOPIC_SELECT_ZONE","EXPERIMENT_RESULT_ZONE","EXPERIMENT_VARIANTS_ZONE","MARKETING_CONSOLE_SECTIONS","MARKETING_ORG_CONSOLE_SECTIONS","BUNDLE_ID","StaffOrgEmailCard","registerMarketingRecordLists","registerMarketingRecordRoutes","MarketingConsolePage","CampaignGlanceCard","InboxCampaignsWidget","EmailMessagesWidget","EmailRecipientsCard","RecordAttributionWidget","BesignerSectionExperiments","registerMarketingConsole","zone","label","surface","description","pluginId","bareZone","layout","registerConsoleExtension","displayName","widgets","slot","CONSOLE_WIDGET_SLOTS","hostDashboard","widgetId","title","Component","besignerInteractions","adminOrgDetail","navItems","href","sections","navTabId","icon","path","header","docsTopic","orgNavItems"],"mappings":"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"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/marketing/src/lib/plugin.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 * as Aglyn from '@aglyn/aglyn'\nimport {\n registerPluginZone,\n type PluginZone,\n} from '@aglyn/aglyn/plugin-manager/plugin-zones'\nimport { mdiBullhornOutline } from '@aglyn/shared-data-mdi'\nimport { lazy } from 'react'\nimport {\n CAMPAIGN_DESIGN_CREATE_ZONE,\n CAMPAIGN_DESIGN_PREVIEW_ZONE,\n CAMPAIGN_SENDER_EDITOR_ZONE,\n CAMPAIGN_TOPIC_OPTIONS_ZONE,\n CAMPAIGN_TOPIC_SELECT_ZONE,\n} from './components/campaign-email-zones'\nimport {\n EXPERIMENT_RESULT_ZONE,\n EXPERIMENT_VARIANTS_ZONE,\n} from './components/experiment-zones'\nimport {\n MARKETING_CONSOLE_SECTIONS,\n MARKETING_ORG_CONSOLE_SECTIONS,\n} from './components/marketing-console-sections'\nimport { registerPluginTransferResourceUi } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources'\nimport { BUNDLE_ID } from './constants/bundle-common'\nimport { StaffOrgEmailCard } from './components/staff-org-email-card.component'\nimport { registerMarketingRecordLists } from './model/overlay-record-list'\nimport { registerMarketingRecordRoutes } from './model/marketing-record-routes'\n\n/** Code-split: the Marketing console page only loads when opened. */\nconst MarketingConsolePage = lazy(\n () => import('./components/marketing-console-page'),\n)\n\n/** Dashboard glance card, loaded only where the shell renders the slot. */\nconst CampaignGlanceCard = lazy(\n () => import('./components/campaign-glance-card.component'),\n)\n\n/**\n * The campaigns drawn in the Inbox's Campaigns section: one site's, or every\n * site's on the organization's Inbox.\n */\nconst InboxCampaignsWidget = lazy(\n () => import('./components/inbox-campaigns-widget'),\n)\n\n/** The Emails page's Messages section: the list, one message, its composer. */\nconst EmailMessagesWidget = lazy(\n () => import('./components/email-messages-widget'),\n)\n\n/** What the sends built from one template did, on the template's page. */\nconst EmailTemplateReportCard = lazy(\n () => import('./components/email-template-report-card'),\n)\n\n/** Who received the sends built from one template, on the template's page. */\nconst EmailRecipientsCard = lazy(\n () => import('./components/email-recipients-card'),\n)\n\n/** Loaded where a record page draws one of the attribution zones. */\nconst RecordAttributionWidget = lazy(\n () => import('./components/record-attribution-widget'),\n)\n\n/** The site's section experiments, reported to the besigner's Interactions section. */\nconst BesignerSectionExperiments = lazy(\n () => import('./components/besigner-section-experiments'),\n)\n\n/**\n * Marketing feature plugin (AGL-395). Console-only — overlays and popups\n * render on published sites through the tenant runtime, not a canvas\n * element of their own, so there is no UI bundle. The console half declares\n * the Marketing nav + page through the ConsoleExtension registry (always-on;\n * the surface itself is not release-flagged — its overlays/A-B cards run\n * their own per-plan checks off the passed `org`). The popup image picker\n * uses the shell's media browser via `useMediaPicker`.\n */\nexport function registerMarketingConsole(): void {\n // How the Import & export hub names campaigns in a workspace package (AGL-3535).\n registerPluginTransferResourceUi('marketing.campaigns', { label: 'Campaigns' }, { pluginId: BUNDLE_ID })\n /*\n * The two positions the A/B testing card hosts (AGL-2914), declared before\n * the extension that draws the card. An id another plugin has already taken\n * is refused, naming both. What each zone hands a widget is carried on its\n * token, in `components/experiment-zones`, so a widget elsewhere is written\n * against the same shape without importing this plugin.\n */\n registerPluginZone(\n {\n zone: EXPERIMENT_VARIANTS_ZONE,\n label: 'A/B test variants',\n surface: 'console',\n description:\n 'In the experiment editor, beside the variants. A widget here proposes copy for the variants the editor holds; the dialog’s Save is the write, and nothing a widget returns starts or changes a test.',\n },\n // Named rather than left to the loader's marker, because this function is\n // also called directly — by a spec, and by an app that loads the plugin\n // without the loader — and a zone with no owner is refused.\n { pluginId: BUNDLE_ID },\n )\n registerPluginZone(\n {\n zone: EXPERIMENT_RESULT_ZONE,\n label: 'A/B test result',\n surface: 'console',\n description:\n 'Below the results of one test. A widget here explains what the figures show. There is nothing to apply, and a widget may not declare a winner the card has not.',\n },\n { pluginId: BUNDLE_ID },\n )\n /*\n * What a campaign email asks of whichever plugin keeps the mail itself —\n * the topic catalog, the sending identities, the design document and its\n * renderer. Each is a zone the composer or a message's page hosts; see\n * `components/campaign-email-zones` for what each hands a widget. All are\n * `bare`: a widget there is a field, a button, a drawer or nothing at all,\n * and the stack's spacing around it would be a gap in a form.\n */\n const bareZone = <Props,>(\n zone: PluginZone<Props>,\n label: string,\n description: string,\n ) =>\n registerPluginZone(\n { zone, label, description, surface: 'console', layout: 'bare' },\n { pluginId: BUNDLE_ID },\n )\n bareZone(\n CAMPAIGN_TOPIC_SELECT_ZONE,\n 'An email’s topic',\n 'In the campaign composer’s field stack. A widget here is the picker for the stream the email belongs to: it is handed the chosen id and reports a new one through `onChange`. The composer records it; the widget writes nothing.',\n )\n bareZone(\n CAMPAIGN_TOPIC_OPTIONS_ZONE,\n 'The topics a campaign can open on',\n 'Beside the drawers that create and edit a campaign. A widget here draws nothing: while `enabled` it reads the streams a recipient can still leave and answers through `onTopics`.',\n )\n bareZone(\n CAMPAIGN_SENDER_EDITOR_ZONE,\n 'Add a sender while composing',\n 'Mounted by the campaign composer once its author asks to add a sender. A widget here is that editor, open in its add mode; `onSaved` names the sender it created.',\n )\n bareZone(\n CAMPAIGN_DESIGN_CREATE_ZONE,\n 'A design for one email',\n 'In the campaign composer, beside the design picker. A widget here is the control that mints a design document and names it through `onCreated`; the composer records the choice on the campaign and opens the editor.',\n )\n bareZone(\n CAMPAIGN_DESIGN_PREVIEW_ZONE,\n 'A sent email as an inbox receives it',\n 'On one message’s page. A widget here renders the stored design, or the plain-text body, through the renderer the send path uses.',\n )\n registerMarketingRecordRoutes()\n // The site's overlays, for another plugin's picker to list (AGL-3080).\n registerMarketingRecordLists()\n Aglyn.registerConsoleExtension({\n pluginId: BUNDLE_ID,\n displayName: 'Marketing',\n /*\n * The host dashboard's `Last campaign` card (AGL-433). A widget rather\n * than a direct import by the dashboard page, so it appears only on\n * workspaces that have the plugin that owns the campaign history — a card\n * about campaigns on a console with no campaigns page could only ever be\n * blank, and its header links straight into `/marketing/campaigns`.\n */\n widgets: [\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.hostDashboard,\n // The id a reader's dashboard preferences already name — it is what\n // `isDashboardWidgetHidden` and the customize order are keyed on, so\n // it is persisted and does not follow the plugin.\n widgetId: 'email-campaign-glance',\n title: 'Last campaign',\n Component: CampaignGlanceCard,\n },\n // Which campaign or link brought a person, drawn where the plugins that\n // keep and list people host a zone for it. They hand over what the\n // record is and its id; neither imports this plugin.\n {\n slot: 'crmRecordAttribution',\n widgetId: 'marketing-crm-record-attribution',\n title: 'Campaign attribution',\n Component: RecordAttributionWidget,\n },\n // The Emails page hosts its Messages section as a zone: a message is\n // one send of a campaign, and every action on it is this plugin's route.\n {\n slot: 'emailMessages',\n widgetId: 'marketing-email-messages',\n title: 'Messages',\n Component: EmailMessagesWidget,\n },\n // A template's page: what the sends built from it did, then who\n // received them. The template is the Email plugin's; the sends are ours.\n {\n slot: 'emailTemplateReport',\n widgetId: 'marketing-email-template-report',\n title: 'Sent from this template',\n Component: EmailTemplateReportCard,\n },\n {\n slot: 'emailTemplateRecipients',\n widgetId: 'marketing-email-template-recipients',\n title: 'Recipients',\n Component: EmailRecipientsCard,\n },\n // The Inbox's Campaigns tab is a zone; this is the card that fills it.\n {\n slot: 'inboxCampaigns',\n widgetId: 'marketing-inbox-campaigns',\n title: 'Campaigns',\n Component: InboxCampaignsWidget,\n },\n {\n slot: 'inboxRecordAttribution',\n widgetId: 'marketing-inbox-record-attribution',\n title: 'Campaign attribution',\n Component: RecordAttributionWidget,\n },\n // The section experiments the besigner's Interactions section badges an\n // element with and starts on a page's element; reported, never drawn.\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.besignerInteractions,\n widgetId: 'marketing-besigner-section-experiments',\n title: 'Section experiments',\n Component: BesignerSectionExperiments,\n },\n // The staff organization page's sends and campaigns (AGL-3380), read\n // only: staff see what the organization sent without joining it.\n {\n slot: Aglyn.CONSOLE_WIDGET_SLOTS.adminOrgDetail,\n widgetId: 'marketing-staff-org-email',\n title: 'Email campaigns',\n Component: StaffOrgEmailCard,\n },\n ],\n navItems: [\n {\n label: 'Marketing',\n href: '/marketing',\n // Sections as ROUTES (AGL-2501): each is a real URL the shell\n // resolves and gates, so the page mounts the one being read.\n sections: MARKETING_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-marketing',\n icon: { path: mdiBullhornOutline.path },\n header: {\n title: 'Marketing',\n icon: { path: mdiBullhornOutline.path },\n docsTopic: 'marketingOverlays',\n },\n Component: MarketingConsolePage,\n },\n ],\n /*\n * The ORGANIZATION's Marketing hub, at `/[orgSlug]/marketing`: every\n * section the site hub has, over every site. Campaigns and their sequence\n * rollups belong to the organization, so this is where one campaign is\n * placed across several sites; overlays, tests and conversions stay each\n * site's and are read across them. Every site's mail is on the\n * organization's Emails page, as a site's is on its own. The page is the\n * same component; handed no site, it renders the org sections.\n *\n * It carries the SITE tab's id on purpose, the way the org CRM tab does:\n * `release_marketing` names `nav-tab-marketing`, so one flag holds both\n * halves of the surface, and the org half cannot ship while the site half\n * is switched off.\n */\n orgNavItems: [\n {\n label: 'Marketing',\n href: '/marketing',\n sections: MARKETING_ORG_CONSOLE_SECTIONS,\n navTabId: 'nav-tab-marketing',\n icon: { path: mdiBullhornOutline.path },\n header: {\n title: 'Marketing',\n icon: { path: mdiBullhornOutline.path },\n docsTopic: 'marketingOverlays',\n },\n Component: MarketingConsolePage,\n },\n ],\n })\n}\n\nexport * from './site'\n"],"names":["Aglyn","registerPluginZone","mdiBullhornOutline","lazy","CAMPAIGN_DESIGN_CREATE_ZONE","CAMPAIGN_DESIGN_PREVIEW_ZONE","CAMPAIGN_SENDER_EDITOR_ZONE","CAMPAIGN_TOPIC_OPTIONS_ZONE","CAMPAIGN_TOPIC_SELECT_ZONE","EXPERIMENT_RESULT_ZONE","EXPERIMENT_VARIANTS_ZONE","MARKETING_CONSOLE_SECTIONS","MARKETING_ORG_CONSOLE_SECTIONS","registerPluginTransferResourceUi","BUNDLE_ID","StaffOrgEmailCard","registerMarketingRecordLists","registerMarketingRecordRoutes","MarketingConsolePage","CampaignGlanceCard","InboxCampaignsWidget","EmailMessagesWidget","EmailTemplateReportCard","EmailRecipientsCard","RecordAttributionWidget","BesignerSectionExperiments","registerMarketingConsole","label","pluginId","zone","surface","description","bareZone","layout","registerConsoleExtension","displayName","widgets","slot","CONSOLE_WIDGET_SLOTS","hostDashboard","widgetId","title","Component","besignerInteractions","adminOrgDetail","navItems","href","sections","navTabId","icon","path","header","docsTopic","orgNavItems"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../libs/plugins/marketing/src/lib/preview-props.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 { collection, getDocs, limit, query, type Firestore } from 'firebase/firestore'\nimport type { HostAction } from '@aglyn/aglyn'\nimport type { SiteRuntimePreviewContext } from '@aglyn/aglyn'\nimport { compileClientAutomations, type RawHostAction } from './model'\n\n/**\n * The marketing site-runtime's client-side page-props for the besigner\n * Preview (AGL-830). No server enricher runs there, so this reads the host's\n * `actions` with the client SDK and compiles them the same way the enricher\n * does — the Preview then mounts the SAME MarketingSiteRuntime the tenant\n * uses, and hover-to-open menus/drawers behave exactly like the live site.\n *\n * Compiled with `actionsEntitled: false`, so only basic PRESENTATIONAL steps\n * (menu/drawer/show-hide/class/nav) run and `hasServerSteps` stays false —\n * preview never fires analytics, webhooks, or server events. `matchAllPaths`\n * because the Preview renders one composed screen and scopes relevance by\n * selector match, not page path.\n */\nexport async function loadMarketingPreviewProps(\n ctx: SiteRuntimePreviewContext,\n): Promise<Record<string, unknown>> {\n const { hostId, firestore } = ctx\n if (!hostId || !firestore) return {}\n try {\n const snapshot = await getDocs(\n query(\n collection(firestore as Firestore, 'hosts', hostId, 'actions'),\n limit(50),\n ),\n )\n const actions: RawHostAction[] = snapshot.docs.map((doc) => ({\n id: doc.id,\n action: doc.data() as HostAction,\n }))\n const clientAutomations = compileClientAutomations(actions, {\n path: '/',\n actionsEntitled: false,\n allowJs: false,\n matchAllPaths: true,\n })\n return { clientAutomations }\n } catch (error) {\n console.error('marketing: preview props load failed', error)\n return {}\n }\n}\n"],"names":["collection","getDocs","limit","query","compileClientAutomations","loadMarketingPreviewProps","ctx","hostId","firestore","snapshot","actions","docs","map","doc","id","action","data","clientAutomations","path","actionsEntitled","allowJs","matchAllPaths","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,QAAwB,qBAAoB;AAGtF,SAASC,wBAAwB,QAA4B,mBAAS;AAEtE;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,0BACpBC,GAA8B;IAE9B,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAAGF;IAC9B,IAAI,CAACC,UAAU,CAACC,WAAW,OAAO,CAAC;IACnC,IAAI;QACF,MAAMC,WAAW,MAAMR,QACrBE,MACEH,WAAWQ,WAAwB,SAASD,QAAQ,YACpDL,MAAM;QAGV,MAAMQ,UAA2BD,SAASE,IAAI,CAACC,GAAG,CAAC,CAACC,MAAS,CAAA;gBAC3DC,IAAID,IAAIC,EAAE;gBACVC,QAAQF,IAAIG,IAAI;YAClB,CAAA;QACA,MAAMC,oBAAoBb,yBAAyBM,SAAS;YAC1DQ,MAAM;YACNC,iBAAiB;YACjBC,SAAS;YACTC,eAAe;QACjB;QACA,OAAO;YAAEJ;QAAkB;IAC7B,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,wCAAwCA;QACtD,OAAO,CAAC;IACV;AACF"}
1
+ {"version":3,"sources":["../../../../../../libs/plugins/marketing/src/lib/preview-props.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 { collection, getDocs, limit, query, type Firestore } from 'firebase/firestore'\nimport type { SiteRuntimePreviewContext } from '@aglyn/aglyn'\nimport { compileClientAutomations, type RawHostAction } from './model'\n\n/**\n * The marketing site-runtime's client-side page-props for the besigner\n * Preview (AGL-830). No server enricher runs there, so this reads the host's\n * `actions` with the client SDK and compiles them the same way the enricher\n * does — the Preview then mounts the SAME MarketingSiteRuntime the tenant\n * uses, and hover-to-open menus/drawers behave exactly like the live site.\n *\n * Compiled with `actionsEntitled: false`, so only basic PRESENTATIONAL steps\n * (menu/drawer/show-hide/class/nav) run and `hasServerSteps` stays false —\n * preview never fires analytics, webhooks, or server events. `matchAllPaths`\n * because the Preview renders one composed screen and scopes relevance by\n * selector match, not page path.\n */\nexport async function loadMarketingPreviewProps(\n ctx: SiteRuntimePreviewContext,\n): Promise<Record<string, unknown>> {\n const { hostId, firestore } = ctx\n if (!hostId || !firestore) return {}\n try {\n const snapshot = await getDocs(\n query(\n collection(firestore as Firestore, 'hosts', hostId, 'actions'),\n limit(50),\n ),\n )\n const actions: RawHostAction[] = snapshot.docs.map((doc) => ({\n id: doc.id,\n action: doc.data() as RawHostAction['action'],\n }))\n const clientAutomations = compileClientAutomations(actions, {\n path: '/',\n actionsEntitled: false,\n allowJs: false,\n matchAllPaths: true,\n })\n return { clientAutomations }\n } catch (error) {\n console.error('marketing: preview props load failed', error)\n return {}\n }\n}\n"],"names":["collection","getDocs","limit","query","compileClientAutomations","loadMarketingPreviewProps","ctx","hostId","firestore","snapshot","actions","docs","map","doc","id","action","data","clientAutomations","path","actionsEntitled","allowJs","matchAllPaths","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED,SAASA,UAAU,EAAEC,OAAO,EAAEC,KAAK,EAAEC,KAAK,QAAwB,qBAAoB;AAEtF,SAASC,wBAAwB,QAA4B,mBAAS;AAEtE;;;;;;;;;;;;CAYC,GACD,OAAO,eAAeC,0BACpBC,GAA8B;IAE9B,MAAM,EAAEC,MAAM,EAAEC,SAAS,EAAE,GAAGF;IAC9B,IAAI,CAACC,UAAU,CAACC,WAAW,OAAO,CAAC;IACnC,IAAI;QACF,MAAMC,WAAW,MAAMR,QACrBE,MACEH,WAAWQ,WAAwB,SAASD,QAAQ,YACpDL,MAAM;QAGV,MAAMQ,UAA2BD,SAASE,IAAI,CAACC,GAAG,CAAC,CAACC,MAAS,CAAA;gBAC3DC,IAAID,IAAIC,EAAE;gBACVC,QAAQF,IAAIG,IAAI;YAClB,CAAA;QACA,MAAMC,oBAAoBb,yBAAyBM,SAAS;YAC1DQ,MAAM;YACNC,iBAAiB;YACjBC,SAAS;YACTC,eAAe;QACjB;QACA,OAAO;YAAEJ;QAAkB;IAC7B,EAAE,OAAOK,OAAO;QACdC,QAAQD,KAAK,CAAC,wCAAwCA;QACtD,OAAO,CAAC;IACV;AACF"}
@@ -0,0 +1,55 @@
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 per-host collection of conversion attribution records. */
18
+ export declare const CAMPAIGN_ATTRIBUTIONS_COLLECTION = "campaignAttributions";
19
+ /**
20
+ * Removes every conversion attributed to one person, across every site.
21
+ *
22
+ * ## Why an erasure reaches these at all
23
+ *
24
+ * An attribution is a claim about a person: "this human came from that
25
+ * campaign and then did this". It is the same personal fact as the click
26
+ * stamp it was derived from, and the same erasure deletes those —
27
+ * `eraseEmailCampaignTouches` drops `campaignTouches` from the person's
28
+ * delivery document, beside the engagement stamps the platform drops. A summary that outlived its source would
29
+ * leave an erasure that removed the evidence and kept the conclusion.
30
+ *
31
+ * ## Why a collection-group query
32
+ *
33
+ * The record is per HOST, because that is where a site's own reporting reads
34
+ * it. An erasure arrives as an ADDRESS and knows nothing about which sites
35
+ * that person ever visited. The collection group is the join between the two,
36
+ * and `personKey` is on the record precisely so one exists — declared in
37
+ * `cloud/firebase-firestore.indexes.json` with `COLLECTION_GROUP` scope,
38
+ * because a collection-group query gets no automatic single-field index and
39
+ * would otherwise fail with FAILED_PRECONDITION at the moment somebody asked
40
+ * to be forgotten.
41
+ *
42
+ * ## The order's own record is NOT touched
43
+ *
44
+ * `emailAttributions` holds a commercial fact about a sale, keyed by the
45
+ * order's id and carrying no address — the revenue join says so where it
46
+ * writes it. This holds a record of a person's behavior under a key derived
47
+ * from their address. Different facts, different answers, and the split is
48
+ * why both are defensible.
49
+ *
50
+ * Never throws: an erasure that fails on one leg must still complete the
51
+ * others, and the caller reports what it removed.
52
+ *
53
+ * @returns how many records were removed.
54
+ */
55
+ export declare function eraseCampaignAttributionsForPersonKey(key: string | null | undefined, firestore?: any): Promise<number>;
@@ -0,0 +1,98 @@
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
+ * Where conversion attributions are kept, and how an erasure reaches them.
18
+ *
19
+ * ## Why this is a leaf module and not part of the join
20
+ *
21
+ * The erasure runs from the plugin's conversion creditor
22
+ * (`conversion-creditor.ts`), which the platform's address erasure asks
23
+ * beside the delivery log — the same request that drops the person's
24
+ * campaign touches (`email-campaign-touch.ts`), which is the point, because
25
+ * an attribution is a conclusion drawn from those touches and a conclusion
26
+ * that outlives its evidence is an erasure that did not happen. The join
27
+ * READS the touch module too (`readEmailCampaignTouch` is one half of the
28
+ * last-touch comparison), and a module that both defined the collection and
29
+ * read the touches would be a second place the creditor's two halves meet.
30
+ * Under jest a cycle between such modules resolves to `undefined` at import
31
+ * time rather than failing loudly, which is how an erasure comes to silently
32
+ * do nothing while its test passes.
33
+ *
34
+ * So the collection name and the sweep live here, importing nothing but
35
+ * Firestore, and both sides import this.
36
+ */ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
37
+ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
38
+ /** The per-host collection of conversion attribution records. */ export const CAMPAIGN_ATTRIBUTIONS_COLLECTION = 'campaignAttributions';
39
+ /** How many records one erasure pass removes per batch. */ const ERASURE_BATCH = 400;
40
+ /**
41
+ * Removes every conversion attributed to one person, across every site.
42
+ *
43
+ * ## Why an erasure reaches these at all
44
+ *
45
+ * An attribution is a claim about a person: "this human came from that
46
+ * campaign and then did this". It is the same personal fact as the click
47
+ * stamp it was derived from, and the same erasure deletes those —
48
+ * `eraseEmailCampaignTouches` drops `campaignTouches` from the person's
49
+ * delivery document, beside the engagement stamps the platform drops. A summary that outlived its source would
50
+ * leave an erasure that removed the evidence and kept the conclusion.
51
+ *
52
+ * ## Why a collection-group query
53
+ *
54
+ * The record is per HOST, because that is where a site's own reporting reads
55
+ * it. An erasure arrives as an ADDRESS and knows nothing about which sites
56
+ * that person ever visited. The collection group is the join between the two,
57
+ * and `personKey` is on the record precisely so one exists — declared in
58
+ * `cloud/firebase-firestore.indexes.json` with `COLLECTION_GROUP` scope,
59
+ * because a collection-group query gets no automatic single-field index and
60
+ * would otherwise fail with FAILED_PRECONDITION at the moment somebody asked
61
+ * to be forgotten.
62
+ *
63
+ * ## The order's own record is NOT touched
64
+ *
65
+ * `emailAttributions` holds a commercial fact about a sale, keyed by the
66
+ * order's id and carrying no address — the revenue join says so where it
67
+ * writes it. This holds a record of a person's behavior under a key derived
68
+ * from their address. Different facts, different answers, and the split is
69
+ * why both are defensible.
70
+ *
71
+ * Never throws: an erasure that fails on one leg must still complete the
72
+ * others, and the caller reports what it removed.
73
+ *
74
+ * @returns how many records were removed.
75
+ */ export async function eraseCampaignAttributionsForPersonKey(key, firestore) {
76
+ if (!key || typeof key !== 'string') return 0;
77
+ const db = firestore != null ? firestore : defaultFirestore();
78
+ let removed = 0;
79
+ try {
80
+ // Bounded rather than `while (true)`: a pathological volume must not be
81
+ // able to hold an erasure request open indefinitely, and the pass that
82
+ // runs next picks up whatever a bounded one left.
83
+ for(let pass = 0; pass < 20; pass += 1){
84
+ const snapshot = await db.collectionGroup(CAMPAIGN_ATTRIBUTIONS_COLLECTION).where('personKey', '==', key).limit(ERASURE_BATCH).get();
85
+ if (snapshot.empty) break;
86
+ const batch = db.batch();
87
+ snapshot.docs.forEach((doc)=>batch.delete(doc.ref));
88
+ await batch.commit();
89
+ removed += snapshot.size;
90
+ if (snapshot.size < ERASURE_BATCH) break;
91
+ }
92
+ } catch (error) {
93
+ console.error('eraseCampaignAttributionsForPersonKey failed', error);
94
+ }
95
+ return removed;
96
+ }
97
+
98
+ //# sourceMappingURL=campaign-attribution-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/campaign-attribution-store.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\n/**\n * Where conversion attributions are kept, and how an erasure reaches them.\n *\n * ## Why this is a leaf module and not part of the join\n *\n * The erasure runs from the plugin's conversion creditor\n * (`conversion-creditor.ts`), which the platform's address erasure asks\n * beside the delivery log — the same request that drops the person's\n * campaign touches (`email-campaign-touch.ts`), which is the point, because\n * an attribution is a conclusion drawn from those touches and a conclusion\n * that outlives its evidence is an erasure that did not happen. The join\n * READS the touch module too (`readEmailCampaignTouch` is one half of the\n * last-touch comparison), and a module that both defined the collection and\n * read the touches would be a second place the creditor's two halves meet.\n * Under jest a cycle between such modules resolves to `undefined` at import\n * time rather than failing loudly, which is how an erasure comes to silently\n * do nothing while its test passes.\n *\n * So the collection name and the sweep live here, importing nothing but\n * Firestore, and both sides import this.\n */\n\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/** The per-host collection of conversion attribution records. */\nexport const CAMPAIGN_ATTRIBUTIONS_COLLECTION = 'campaignAttributions'\n\n/** How many records one erasure pass removes per batch. */\nconst ERASURE_BATCH = 400\n\n/**\n * Removes every conversion attributed to one person, across every site.\n *\n * ## Why an erasure reaches these at all\n *\n * An attribution is a claim about a person: \"this human came from that\n * campaign and then did this\". It is the same personal fact as the click\n * stamp it was derived from, and the same erasure deletes those —\n * `eraseEmailCampaignTouches` drops `campaignTouches` from the person's\n * delivery document, beside the engagement stamps the platform drops. A summary that outlived its source would\n * leave an erasure that removed the evidence and kept the conclusion.\n *\n * ## Why a collection-group query\n *\n * The record is per HOST, because that is where a site's own reporting reads\n * it. An erasure arrives as an ADDRESS and knows nothing about which sites\n * that person ever visited. The collection group is the join between the two,\n * and `personKey` is on the record precisely so one exists — declared in\n * `cloud/firebase-firestore.indexes.json` with `COLLECTION_GROUP` scope,\n * because a collection-group query gets no automatic single-field index and\n * would otherwise fail with FAILED_PRECONDITION at the moment somebody asked\n * to be forgotten.\n *\n * ## The order's own record is NOT touched\n *\n * `emailAttributions` holds a commercial fact about a sale, keyed by the\n * order's id and carrying no address — the revenue join says so where it\n * writes it. This holds a record of a person's behavior under a key derived\n * from their address. Different facts, different answers, and the split is\n * why both are defensible.\n *\n * Never throws: an erasure that fails on one leg must still complete the\n * others, and the caller reports what it removed.\n *\n * @returns how many records were removed.\n */\nexport async function eraseCampaignAttributionsForPersonKey(\n key: string | null | undefined,\n firestore?: any,\n): Promise<number> {\n if (!key || typeof key !== 'string') return 0\n const db = firestore ?? defaultFirestore()\n let removed = 0\n try {\n // Bounded rather than `while (true)`: a pathological volume must not be\n // able to hold an erasure request open indefinitely, and the pass that\n // runs next picks up whatever a bounded one left.\n for (let pass = 0; pass < 20; pass += 1) {\n const snapshot = await db\n .collectionGroup(CAMPAIGN_ATTRIBUTIONS_COLLECTION)\n .where('personKey', '==', key)\n .limit(ERASURE_BATCH)\n .get()\n if (snapshot.empty) break\n const batch = db.batch()\n snapshot.docs.forEach((doc: any) => batch.delete(doc.ref))\n await batch.commit()\n removed += snapshot.size\n if (snapshot.size < ERASURE_BATCH) break\n }\n } catch (error) {\n console.error('eraseCampaignAttributionsForPersonKey failed', error)\n }\n return removed\n}\n"],"names":["firebaseAdmin","defaultFirestore","app","firestore","CAMPAIGN_ATTRIBUTIONS_COLLECTION","ERASURE_BATCH","eraseCampaignAttributionsForPersonKey","key","db","removed","pass","snapshot","collectionGroup","where","limit","get","empty","batch","docs","forEach","doc","delete","ref","commit","size","error","console"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;;;;;;CAoBC,GAED,OAAOA,mBAAmB,iDAAgD;AAE1E,MAAMC,mBAAmB,IAAMD,cAAcE,GAAG,GAAGC,SAAS;AAE5D,+DAA+D,GAC/D,OAAO,MAAMC,mCAAmC,uBAAsB;AAEtE,yDAAyD,GACzD,MAAMC,gBAAgB;AAEtB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAmCC,GACD,OAAO,eAAeC,sCACpBC,GAA8B,EAC9BJ,SAAe;IAEf,IAAI,CAACI,OAAO,OAAOA,QAAQ,UAAU,OAAO;IAC5C,MAAMC,KAAKL,oBAAAA,YAAaF;IACxB,IAAIQ,UAAU;IACd,IAAI;QACF,wEAAwE;QACxE,uEAAuE;QACvE,kDAAkD;QAClD,IAAK,IAAIC,OAAO,GAAGA,OAAO,IAAIA,QAAQ,EAAG;YACvC,MAAMC,WAAW,MAAMH,GACpBI,eAAe,CAACR,kCAChBS,KAAK,CAAC,aAAa,MAAMN,KACzBO,KAAK,CAACT,eACNU,GAAG;YACN,IAAIJ,SAASK,KAAK,EAAE;YACpB,MAAMC,QAAQT,GAAGS,KAAK;YACtBN,SAASO,IAAI,CAACC,OAAO,CAAC,CAACC,MAAaH,MAAMI,MAAM,CAACD,IAAIE,GAAG;YACxD,MAAML,MAAMM,MAAM;YAClBd,WAAWE,SAASa,IAAI;YACxB,IAAIb,SAASa,IAAI,GAAGnB,eAAe;QACrC;IACF,EAAE,OAAOoB,OAAO;QACdC,QAAQD,KAAK,CAAC,gDAAgDA;IAChE;IACA,OAAOhB;AACT"}