@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,322 @@
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_ATTRIBUTION_MODEL, EMAIL_ATTRIBUTION_WINDOW_DAYS, EMAIL_ATTRIBUTION_WINDOW_MS, emailTouchIsInWindow } from "@aglyn/shared-util-email";
19
+ import { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
20
+ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
21
+ import { resolveCampaignSendRef } from "./campaign-conversion-attribution.js";
22
+ import { readEmailCampaignTouch } from "./email-campaign-touch.js";
23
+ const defaultFirestore = ()=>firebaseAdmin.app().firestore();
24
+ /**
25
+ * THE COMMERCE↔EMAIL JOIN — an order, credited to the campaign that led to it.
26
+ *
27
+ * ## Why this is a join
28
+ *
29
+ * Every compared ESP attributes revenue through an integration into a store
30
+ * it does not own: a catalog sync, an on-site tracking snippet, an identity
31
+ * graph, and a window wide enough to absorb what the reconciliation misses.
32
+ * Commerce here is first-party and runs on the merchant's own Stripe Connect
33
+ * account, so the click and the order are two rows in one database keyed the
34
+ * same way. The window is not a fudge factor here — it is the model, and
35
+ * nothing else about the number is estimated.
36
+ *
37
+ * ## Three writes, and what each one is for
38
+ *
39
+ * - **The touch**, `emailDeliveries/{personKey}.campaignTouches[hostId]`,
40
+ * written by the delivery webhook on a click (`email-campaign-touch.ts`).
41
+ * It lives on the person's delivery document, where the erasure that
42
+ * removes the person's mail asks this plugin to remove it too.
43
+ * - **The attribution record**, `hosts/{hostId}/emailAttributions/{orderId}`,
44
+ * written here when an order is credited. It is the audit trail — which
45
+ * campaign, which click, which model, which window — and it is what the
46
+ * refund path reads to find out which campaign to take the money back off.
47
+ * - **The rollup**, `campaigns/{campaignId}/reports/revenue`, incremented
48
+ * here. One document per campaign, so the report reads it whole.
49
+ *
50
+ * ## Why the record is a document and not a field on the order
51
+ *
52
+ * Three reasons, and the first is decisive. `create()` fails when the
53
+ * document already exists, which is exact idempotency for free: webhook
54
+ * delivery is at-least-once, and a redelivered purchase must not be able to
55
+ * credit a campaign twice. Second, not every purchase door writes an order —
56
+ * a booking is a sale with no order document — and a field would have had to
57
+ * either skip those or conjure the document, and conjuring is the
58
+ * phantom-document shape this codebase spent a sweep removing. Third, the
59
+ * order document is read by the console list, the fulfilment path, the
60
+ * supplier outbox and the CSV export, and none of them wants a field about
61
+ * email.
62
+ *
63
+ * ## Never throws
64
+ *
65
+ * Same contract as the purchase door that asks for it (the person capture's
66
+ * `upsertHostContact`, through `plugin-conversion-credit.ts`), and for the
67
+ * same reason: the money has already moved and the order already records it, so
68
+ * nothing here may fail a sale. A lost attribution understates a campaign; a
69
+ * thrown one loses a checkout.
70
+ */ /** The per-host collection of attribution records. */ export const EMAIL_ATTRIBUTIONS_COLLECTION = 'emailAttributions';
71
+ /** The single rollup document under a campaign. */ export const CAMPAIGN_REVENUE_REPORT_DOC = 'revenue';
72
+ /**
73
+ * The currency an amount is recorded under when the caller does not say.
74
+ *
75
+ * Every checkout door in this repo — cart, buy-now, POS, draft orders,
76
+ * reservations, subscriptions — writes `currency: 'usd'` onto the Stripe line
77
+ * items, and no order document carries a currency field to read back. So this
78
+ * is a statement about what the code charges rather than a guess about the
79
+ * money. The parameter exists so a door that ever charges in something else
80
+ * says so and lands in its own bucket, because the one thing the report may
81
+ * never do is add two currencies together.
82
+ */ export const DEFAULT_ATTRIBUTION_CURRENCY = 'usd';
83
+ /** Normalizes a currency code to the key its bucket is stored under. */ function currencyKey(raw) {
84
+ const code = String(raw != null ? raw : '').trim().toLowerCase();
85
+ // Letters only: the code becomes a MAP KEY, and a key carrying a dot would
86
+ // be unreachable by any dotted field path a later reader wants to use.
87
+ return /^[a-z]{3}$/.test(code) ? code : DEFAULT_ATTRIBUTION_CURRENCY;
88
+ }
89
+ /** A positive integer number of minor units, or 0. */ function minorUnits(raw) {
90
+ const value = Math.round(Number(raw));
91
+ return Number.isFinite(value) && value > 0 ? value : 0;
92
+ }
93
+ /**
94
+ * Credits one order to the campaign whose link the buyer last clicked.
95
+ *
96
+ * ## What happens when there is nobody to credit
97
+ *
98
+ * Each case answers `null` and writes NOTHING, which is the whole design:
99
+ *
100
+ * - **No email on the order.** A guest checkout that never identified its
101
+ * buyer cannot be joined to anybody's clicks. There is no fallback and
102
+ * there deliberately is not one — the alternatives are an IP or a device
103
+ * guess, which is the probabilistic attribution owning the checkout exists
104
+ * to avoid.
105
+ * - **A guest with an email but no contact record.** This is ATTRIBUTED
106
+ * normally. The join keys on the address hash, exactly as the touch and
107
+ * the suppression list do, so it never asks whether a contact document
108
+ * exists — which matters, because contact creation is audience-band gated
109
+ * and a Free org's dropped contact would otherwise silently drop the
110
+ * revenue with it.
111
+ * - **No touch, or a touch on another site.** Nobody clicked, so nobody is
112
+ * credited. The touch map is keyed by host and the send path refuses
113
+ * cross-site reach; the revenue join agrees with it.
114
+ * - **A touch outside the window**, in either direction. See
115
+ * {@link emailTouchIsInWindow} — a click AFTER the order is the receipt,
116
+ * not the cause.
117
+ *
118
+ * None of these is counted anywhere. A miss costs no write, which is what
119
+ * keeps the ordinary order — placed by somebody who is not on the mailing
120
+ * list at all — at exactly one document read. What it means for the report is
121
+ * stated on the report: the figure counts orders it could join, and is a
122
+ * floor.
123
+ *
124
+ * @returns the record written, or `null` when nothing was credited.
125
+ */ export async function attributeOrderToEmail(options, firestore) {
126
+ try {
127
+ var _options_hostId, _options_orderId, _options_orderedAtMs, _options_email;
128
+ const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '');
129
+ const orderId = String((_options_orderId = options.orderId) != null ? _options_orderId : '');
130
+ const amountCents = minorUnits(options.amountCents);
131
+ if (!isDocumentId(hostId) || !isDocumentId(orderId)) return null;
132
+ if (!amountCents) return null;
133
+ const orderedAtMs = Number((_options_orderedAtMs = options.orderedAtMs) != null ? _options_orderedAtMs : Date.now());
134
+ const db = firestore != null ? firestore : defaultFirestore();
135
+ /*
136
+ * `String()` on an `unknown`, then let `emailSuppressionKey` decide.
137
+ *
138
+ * The buyer's address arrives as the ORDER recorded it, which is not
139
+ * consistently a string — the same rawness `recordContactRefund` takes
140
+ * `email: unknown` for. The key derivation is the one place that knows
141
+ * how an address becomes a document id, so nothing here tries to
142
+ * pre-clean it; a value that is not an address answers `null` there and
143
+ * the order is credited to nobody.
144
+ */ const rawEmail = typeof options.email === 'string' ? options.email : String((_options_email = options.email) != null ? _options_email : '');
145
+ const touch = await readEmailCampaignTouch(rawEmail, hostId, db);
146
+ if (!touch) return null;
147
+ if (!isDocumentId(touch.campaignId)) return null;
148
+ if (!emailTouchIsInWindow(touch.clickedAtMs, orderedAtMs)) return null;
149
+ const record = {
150
+ campaignId: touch.campaignId,
151
+ clickedAtMs: touch.clickedAtMs,
152
+ orderedAtMs,
153
+ amountCents,
154
+ currency: currencyKey(options.currency),
155
+ model: EMAIL_ATTRIBUTION_MODEL,
156
+ windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS
157
+ };
158
+ const hostRef = db.collection('hosts').doc(hostId);
159
+ /*
160
+ * `create()`, never `set()`. It fails with ALREADY_EXISTS when this order
161
+ * has been credited before, and that failure is the idempotency: a
162
+ * webhook redelivery, a retried checkout completion and a replayed event
163
+ * all land here a second time, and all three must leave the rollup where
164
+ * they found it. The increment below is reached only when the create
165
+ * succeeded, so the two can never disagree about whether this order was
166
+ * counted.
167
+ */ try {
168
+ await hostRef.collection(EMAIL_ATTRIBUTIONS_COLLECTION).doc(orderId).create(_extends({}, record, {
169
+ createdAt: FieldValue.serverTimestamp()
170
+ }));
171
+ } catch (unused) {
172
+ return null;
173
+ }
174
+ /*
175
+ * A merge-set that CREATES, unlike the campaign counters the delivery
176
+ * webhook writes through `updateExisting`.
177
+ *
178
+ * The distinction is which document is being conjured. Those counters
179
+ * refuse to create because a merge-set against a DELETED CAMPAIGN
180
+ * resurrects the campaign itself, as a husk holding a stats map and no
181
+ * subject. This writes a subcollection document UNDER a campaign, so a
182
+ * campaign that no longer exists gains an orphaned report rather than
183
+ * coming back to life in the merchant's history — and the campaign was
184
+ * proven to exist a moment ago, when its click wrote the touch.
185
+ *
186
+ * Every amount is an increment, so two orders settling at once both land.
187
+ *
188
+ * The send is found where it is — the org's `campaigns`, or the site's
189
+ * for a send the migration has not reached — and a send in neither
190
+ * place was discarded: the record above stands, and no report is
191
+ * conjured under nothing.
192
+ */ const currency = record.currency;
193
+ const sendRef = await resolveCampaignSendRef({
194
+ hostId,
195
+ sendId: touch.campaignId,
196
+ firestore: db
197
+ });
198
+ if (!sendRef) return record;
199
+ await sendRef.collection('reports').doc(CAMPAIGN_REVENUE_REPORT_DOC).set({
200
+ model: EMAIL_ATTRIBUTION_MODEL,
201
+ windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,
202
+ byCurrency: {
203
+ [currency]: {
204
+ grossCents: FieldValue.increment(amountCents),
205
+ orders: FieldValue.increment(1)
206
+ }
207
+ },
208
+ updatedAt: FieldValue.serverTimestamp()
209
+ }, {
210
+ merge: true
211
+ });
212
+ return record;
213
+ } catch (error) {
214
+ console.error('attributeOrderToEmail failed', error);
215
+ return null;
216
+ }
217
+ }
218
+ /**
219
+ * Takes back revenue a campaign was credited with, when it is refunded.
220
+ *
221
+ * ## Recorded beside the gross, never subtracted from it
222
+ *
223
+ * `grossCents` is left exactly as it is and the reversal lands in
224
+ * `refundedCents` next to it. This is the shape `contact-refund.ts` chose for
225
+ * `ltvCents`/`refundedCents` and the orders CSV chose for
226
+ * `amountUsd`/`refundedUsd`, and it is chosen a third time here so all three
227
+ * answer "what did this earn, net" identically. Decrementing would make a
228
+ * stored number mean one thing for rollups written before a refund and
229
+ * another after, with nothing on the document to tell them apart — and would
230
+ * put the question "can it go negative" onto storage, where the answer cannot
231
+ * be clamped without destroying evidence. Both stored figures are monotonic
232
+ * counters of money that really moved in one direction; the derived net is
233
+ * clamped at the point of display and nowhere else.
234
+ *
235
+ * ## The currency comes from the RECORD, not from the caller
236
+ *
237
+ * A refund reverses a specific sale, and the bucket it comes out of has to be
238
+ * the bucket it went into. Reading the currency back off the attribution
239
+ * record is what guarantees that, and it is the second thing the record is
240
+ * for.
241
+ *
242
+ * ## Chargebacks come through here too
243
+ *
244
+ * Money reversed is money reversed, whichever door it left by, so a lost
245
+ * dispute reverses the credit the same way a refund does. `kind` exists for
246
+ * the caller's clarity and changes nothing about the arithmetic — the same
247
+ * choice `recordContactRefund` made one field along.
248
+ *
249
+ * @returns whether a reversal was recorded.
250
+ */ export async function reverseEmailAttributedRevenue(options, firestore) {
251
+ try {
252
+ var _options_hostId, _options_orderId, _snapshot_data, _record_campaignId;
253
+ const hostId = String((_options_hostId = options.hostId) != null ? _options_hostId : '');
254
+ const orderId = String((_options_orderId = options.orderId) != null ? _options_orderId : '');
255
+ const amountCents = minorUnits(options.amountCents);
256
+ if (!isDocumentId(hostId) || !isDocumentId(orderId)) return false;
257
+ if (!amountCents) return false;
258
+ const db = firestore != null ? firestore : defaultFirestore();
259
+ const hostRef = db.collection('hosts').doc(hostId);
260
+ const snapshot = await hostRef.collection(EMAIL_ATTRIBUTIONS_COLLECTION).doc(orderId).get();
261
+ // The ordinary case, and not an error: most orders were never credited to
262
+ // a campaign, so most refunds have nothing to reverse.
263
+ if (!snapshot.exists) return false;
264
+ const record = (_snapshot_data = snapshot.data()) != null ? _snapshot_data : {};
265
+ const campaignId = String((_record_campaignId = record.campaignId) != null ? _record_campaignId : '');
266
+ if (!isDocumentId(campaignId)) return false;
267
+ const currency = currencyKey(record.currency);
268
+ /*
269
+ * NOT capped against what was credited, and that is deliberate. The
270
+ * caller already caps each attempt against what is left on the ORDER, so
271
+ * several partials sum to at most the order total; capping again here
272
+ * against the attributed amount would silently discard the reversal of an
273
+ * order refunded for more than the amount the campaign was credited with
274
+ * — which happens when the credit was the charge and the refund includes
275
+ * something the credit did not. The stored pair keeps both true figures
276
+ * and the reader clamps the net it prints.
277
+ *
278
+ * The send is found where it is, as the credit found it; a send that
279
+ * has since been discarded has no rollup left to reverse, and the
280
+ * record below still takes the refund.
281
+ */ const sendRef = await resolveCampaignSendRef({
282
+ hostId,
283
+ sendId: campaignId,
284
+ firestore: db
285
+ });
286
+ await (sendRef == null ? void 0 : sendRef.collection('reports').doc(CAMPAIGN_REVENUE_REPORT_DOC).set({
287
+ byCurrency: {
288
+ [currency]: _extends({
289
+ refundedCents: FieldValue.increment(amountCents)
290
+ }, options.closedTheOrder ? {
291
+ refundedOrders: FieldValue.increment(1)
292
+ } : {})
293
+ },
294
+ updatedAt: FieldValue.serverTimestamp()
295
+ }, {
296
+ merge: true
297
+ }));
298
+ /*
299
+ * Stamped on the record as well as counted in the rollup. The rollup is a
300
+ * sum and cannot say WHICH orders came back; this is the per-order half
301
+ * of the same fact, and it is what a merchant asking "why did this
302
+ * campaign's revenue drop" is eventually going to need. Increments, so
303
+ * two partials on one order both land.
304
+ */ await snapshot.ref.set(_extends({
305
+ refundedCents: FieldValue.increment(amountCents),
306
+ lastRefundAtMs: Date.now()
307
+ }, options.kind === 'chargeback' ? {
308
+ chargedBack: true
309
+ } : {}, options.closedTheOrder ? {
310
+ fullyRefunded: true
311
+ } : {}), {
312
+ merge: true
313
+ }).catch(()=>undefined);
314
+ return true;
315
+ } catch (error) {
316
+ console.error('reverseEmailAttributedRevenue failed', error);
317
+ return false;
318
+ }
319
+ }
320
+ /** The window, re-exported so a caller needs one import for the whole join. */ export { EMAIL_ATTRIBUTION_WINDOW_MS };
321
+
322
+ //# sourceMappingURL=email-revenue-attribution.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-revenue-attribution.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 {\n EMAIL_ATTRIBUTION_MODEL,\n EMAIL_ATTRIBUTION_WINDOW_DAYS,\n EMAIL_ATTRIBUTION_WINDOW_MS,\n emailTouchIsInWindow,\n} from '@aglyn/shared-util-email'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveCampaignSendRef } from './campaign-conversion-attribution'\nimport { readEmailCampaignTouch } from './email-campaign-touch'\n\nconst defaultFirestore = () => firebaseAdmin.app().firestore()\n\n/**\n * THE COMMERCE↔EMAIL JOIN — an order, credited to the campaign that led to it.\n *\n * ## Why this is a join\n *\n * Every compared ESP attributes revenue through an integration into a store\n * it does not own: a catalog sync, an on-site tracking snippet, an identity\n * graph, and a window wide enough to absorb what the reconciliation misses.\n * Commerce here is first-party and runs on the merchant's own Stripe Connect\n * account, so the click and the order are two rows in one database keyed the\n * same way. The window is not a fudge factor here — it is the model, and\n * nothing else about the number is estimated.\n *\n * ## Three writes, and what each one is for\n *\n * - **The touch**, `emailDeliveries/{personKey}.campaignTouches[hostId]`,\n * written by the delivery webhook on a click (`email-campaign-touch.ts`).\n * It lives on the person's delivery document, where the erasure that\n * removes the person's mail asks this plugin to remove it too.\n * - **The attribution record**, `hosts/{hostId}/emailAttributions/{orderId}`,\n * written here when an order is credited. It is the audit trail — which\n * campaign, which click, which model, which window — and it is what the\n * refund path reads to find out which campaign to take the money back off.\n * - **The rollup**, `campaigns/{campaignId}/reports/revenue`, incremented\n * here. One document per campaign, so the report reads it whole.\n *\n * ## Why the record is a document and not a field on the order\n *\n * Three reasons, and the first is decisive. `create()` fails when the\n * document already exists, which is exact idempotency for free: webhook\n * delivery is at-least-once, and a redelivered purchase must not be able to\n * credit a campaign twice. Second, not every purchase door writes an order —\n * a booking is a sale with no order document — and a field would have had to\n * either skip those or conjure the document, and conjuring is the\n * phantom-document shape this codebase spent a sweep removing. Third, the\n * order document is read by the console list, the fulfilment path, the\n * supplier outbox and the CSV export, and none of them wants a field about\n * email.\n *\n * ## Never throws\n *\n * Same contract as the purchase door that asks for it (the person capture's\n * `upsertHostContact`, through `plugin-conversion-credit.ts`), and for the\n * same reason: the money has already moved and the order already records it, so\n * nothing here may fail a sale. A lost attribution understates a campaign; a\n * thrown one loses a checkout.\n */\n\n/** The per-host collection of attribution records. */\nexport const EMAIL_ATTRIBUTIONS_COLLECTION = 'emailAttributions'\n\n/** The single rollup document under a campaign. */\nexport const CAMPAIGN_REVENUE_REPORT_DOC = 'revenue'\n\n/**\n * The currency an amount is recorded under when the caller does not say.\n *\n * Every checkout door in this repo — cart, buy-now, POS, draft orders,\n * reservations, subscriptions — writes `currency: 'usd'` onto the Stripe line\n * items, and no order document carries a currency field to read back. So this\n * is a statement about what the code charges rather than a guess about the\n * money. The parameter exists so a door that ever charges in something else\n * says so and lands in its own bucket, because the one thing the report may\n * never do is add two currencies together.\n */\nexport const DEFAULT_ATTRIBUTION_CURRENCY = 'usd'\n\n/** What one attribution record holds. */\nexport interface EmailAttributionRecord {\n campaignId: string\n /** When the credited click happened. */\n clickedAtMs: number\n /** When the order was placed. */\n orderedAtMs: number\n /** Minor units credited, gross — the amount the buyer was charged. */\n amountCents: number\n /** Lowercase currency code the amount is in. */\n currency: string\n /** The model this credit was decided under. */\n model: string\n /** The window, in days, it was decided inside. */\n windowDays: number\n}\n\n/** Normalizes a currency code to the key its bucket is stored under. */\nfunction currencyKey(raw: unknown): string {\n const code = String(raw ?? '')\n .trim()\n .toLowerCase()\n // Letters only: the code becomes a MAP KEY, and a key carrying a dot would\n // be unreachable by any dotted field path a later reader wants to use.\n return /^[a-z]{3}$/.test(code) ? code : DEFAULT_ATTRIBUTION_CURRENCY\n}\n\n/** A positive integer number of minor units, or 0. */\nfunction minorUnits(raw: unknown): number {\n const value = Math.round(Number(raw))\n return Number.isFinite(value) && value > 0 ? value : 0\n}\n\n/**\n * Credits one order to the campaign whose link the buyer last clicked.\n *\n * ## What happens when there is nobody to credit\n *\n * Each case answers `null` and writes NOTHING, which is the whole design:\n *\n * - **No email on the order.** A guest checkout that never identified its\n * buyer cannot be joined to anybody's clicks. There is no fallback and\n * there deliberately is not one — the alternatives are an IP or a device\n * guess, which is the probabilistic attribution owning the checkout exists\n * to avoid.\n * - **A guest with an email but no contact record.** This is ATTRIBUTED\n * normally. The join keys on the address hash, exactly as the touch and\n * the suppression list do, so it never asks whether a contact document\n * exists — which matters, because contact creation is audience-band gated\n * and a Free org's dropped contact would otherwise silently drop the\n * revenue with it.\n * - **No touch, or a touch on another site.** Nobody clicked, so nobody is\n * credited. The touch map is keyed by host and the send path refuses\n * cross-site reach; the revenue join agrees with it.\n * - **A touch outside the window**, in either direction. See\n * {@link emailTouchIsInWindow} — a click AFTER the order is the receipt,\n * not the cause.\n *\n * None of these is counted anywhere. A miss costs no write, which is what\n * keeps the ordinary order — placed by somebody who is not on the mailing\n * list at all — at exactly one document read. What it means for the report is\n * stated on the report: the figure counts orders it could join, and is a\n * floor.\n *\n * @returns the record written, or `null` when nothing was credited.\n */\nexport async function attributeOrderToEmail(\n options: {\n hostId: string\n /** The order, booking or invoice the money came in on. */\n orderId: string\n /** The buyer as the sale recorded them, raw — normalized downstream. */\n email: unknown\n /** Gross minor units the buyer was charged. */\n amountCents: number\n /** Lowercase currency code, when the door knows one. */\n currency?: string\n /** When the order was placed. Defaults to now. */\n orderedAtMs?: number\n },\n firestore?: any,\n): Promise<EmailAttributionRecord | null> {\n try {\n const hostId = String(options.hostId ?? '')\n const orderId = String(options.orderId ?? '')\n const amountCents = minorUnits(options.amountCents)\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return null\n if (!amountCents) return null\n\n const orderedAtMs = Number(options.orderedAtMs ?? Date.now())\n const db = firestore ?? defaultFirestore()\n\n /*\n * `String()` on an `unknown`, then let `emailSuppressionKey` decide.\n *\n * The buyer's address arrives as the ORDER recorded it, which is not\n * consistently a string — the same rawness `recordContactRefund` takes\n * `email: unknown` for. The key derivation is the one place that knows\n * how an address becomes a document id, so nothing here tries to\n * pre-clean it; a value that is not an address answers `null` there and\n * the order is credited to nobody.\n */\n const rawEmail =\n typeof options.email === 'string' ? options.email : String(options.email ?? '')\n const touch = await readEmailCampaignTouch(rawEmail, hostId, db)\n if (!touch) return null\n if (!isDocumentId(touch.campaignId)) return null\n if (!emailTouchIsInWindow(touch.clickedAtMs, orderedAtMs)) return null\n\n const record: EmailAttributionRecord = {\n campaignId: touch.campaignId,\n clickedAtMs: touch.clickedAtMs,\n orderedAtMs,\n amountCents,\n currency: currencyKey(options.currency),\n model: EMAIL_ATTRIBUTION_MODEL,\n windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,\n }\n\n const hostRef = db.collection('hosts').doc(hostId)\n /*\n * `create()`, never `set()`. It fails with ALREADY_EXISTS when this order\n * has been credited before, and that failure is the idempotency: a\n * webhook redelivery, a retried checkout completion and a replayed event\n * all land here a second time, and all three must leave the rollup where\n * they found it. The increment below is reached only when the create\n * succeeded, so the two can never disagree about whether this order was\n * counted.\n */\n try {\n await hostRef\n .collection(EMAIL_ATTRIBUTIONS_COLLECTION)\n .doc(orderId)\n .create({ ...record, createdAt: FieldValue.serverTimestamp() })\n } catch {\n return null\n }\n\n /*\n * A merge-set that CREATES, unlike the campaign counters the delivery\n * webhook writes through `updateExisting`.\n *\n * The distinction is which document is being conjured. Those counters\n * refuse to create because a merge-set against a DELETED CAMPAIGN\n * resurrects the campaign itself, as a husk holding a stats map and no\n * subject. This writes a subcollection document UNDER a campaign, so a\n * campaign that no longer exists gains an orphaned report rather than\n * coming back to life in the merchant's history — and the campaign was\n * proven to exist a moment ago, when its click wrote the touch.\n *\n * Every amount is an increment, so two orders settling at once both land.\n *\n * The send is found where it is — the org's `campaigns`, or the site's\n * for a send the migration has not reached — and a send in neither\n * place was discarded: the record above stands, and no report is\n * conjured under nothing.\n */\n const currency = record.currency\n const sendRef = await resolveCampaignSendRef({ hostId, sendId: touch.campaignId, firestore: db })\n if (!sendRef) return record\n await sendRef\n .collection('reports')\n .doc(CAMPAIGN_REVENUE_REPORT_DOC)\n .set(\n {\n model: EMAIL_ATTRIBUTION_MODEL,\n windowDays: EMAIL_ATTRIBUTION_WINDOW_DAYS,\n byCurrency: {\n [currency]: {\n grossCents: FieldValue.increment(amountCents),\n orders: FieldValue.increment(1),\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n return record\n } catch (error) {\n console.error('attributeOrderToEmail failed', error)\n return null\n }\n}\n\n/**\n * Takes back revenue a campaign was credited with, when it is refunded.\n *\n * ## Recorded beside the gross, never subtracted from it\n *\n * `grossCents` is left exactly as it is and the reversal lands in\n * `refundedCents` next to it. This is the shape `contact-refund.ts` chose for\n * `ltvCents`/`refundedCents` and the orders CSV chose for\n * `amountUsd`/`refundedUsd`, and it is chosen a third time here so all three\n * answer \"what did this earn, net\" identically. Decrementing would make a\n * stored number mean one thing for rollups written before a refund and\n * another after, with nothing on the document to tell them apart — and would\n * put the question \"can it go negative\" onto storage, where the answer cannot\n * be clamped without destroying evidence. Both stored figures are monotonic\n * counters of money that really moved in one direction; the derived net is\n * clamped at the point of display and nowhere else.\n *\n * ## The currency comes from the RECORD, not from the caller\n *\n * A refund reverses a specific sale, and the bucket it comes out of has to be\n * the bucket it went into. Reading the currency back off the attribution\n * record is what guarantees that, and it is the second thing the record is\n * for.\n *\n * ## Chargebacks come through here too\n *\n * Money reversed is money reversed, whichever door it left by, so a lost\n * dispute reverses the credit the same way a refund does. `kind` exists for\n * the caller's clarity and changes nothing about the arithmetic — the same\n * choice `recordContactRefund` made one field along.\n *\n * @returns whether a reversal was recorded.\n */\nexport async function reverseEmailAttributedRevenue(\n options: {\n hostId: string\n orderId: string\n /** Minor units reversed by THIS attempt, never the order total. */\n amountCents: number\n /** True only for the write that moved the order into `refunded`. */\n closedTheOrder: boolean\n kind?: 'refund' | 'chargeback'\n },\n firestore?: any,\n): Promise<boolean> {\n try {\n const hostId = String(options.hostId ?? '')\n const orderId = String(options.orderId ?? '')\n const amountCents = minorUnits(options.amountCents)\n if (!isDocumentId(hostId) || !isDocumentId(orderId)) return false\n if (!amountCents) return false\n\n const db = firestore ?? defaultFirestore()\n const hostRef = db.collection('hosts').doc(hostId)\n const snapshot = await hostRef\n .collection(EMAIL_ATTRIBUTIONS_COLLECTION)\n .doc(orderId)\n .get()\n // The ordinary case, and not an error: most orders were never credited to\n // a campaign, so most refunds have nothing to reverse.\n if (!snapshot.exists) return false\n\n const record = snapshot.data() ?? {}\n const campaignId = String(record.campaignId ?? '')\n if (!isDocumentId(campaignId)) return false\n const currency = currencyKey(record.currency)\n\n /*\n * NOT capped against what was credited, and that is deliberate. The\n * caller already caps each attempt against what is left on the ORDER, so\n * several partials sum to at most the order total; capping again here\n * against the attributed amount would silently discard the reversal of an\n * order refunded for more than the amount the campaign was credited with\n * — which happens when the credit was the charge and the refund includes\n * something the credit did not. The stored pair keeps both true figures\n * and the reader clamps the net it prints.\n *\n * The send is found where it is, as the credit found it; a send that\n * has since been discarded has no rollup left to reverse, and the\n * record below still takes the refund.\n */\n const sendRef = await resolveCampaignSendRef({ hostId, sendId: campaignId, firestore: db })\n await sendRef\n ?.collection('reports')\n .doc(CAMPAIGN_REVENUE_REPORT_DOC)\n .set(\n {\n byCurrency: {\n [currency]: {\n refundedCents: FieldValue.increment(amountCents),\n ...(options.closedTheOrder\n ? { refundedOrders: FieldValue.increment(1) }\n : {}),\n },\n },\n updatedAt: FieldValue.serverTimestamp(),\n },\n { merge: true },\n )\n\n /*\n * Stamped on the record as well as counted in the rollup. The rollup is a\n * sum and cannot say WHICH orders came back; this is the per-order half\n * of the same fact, and it is what a merchant asking \"why did this\n * campaign's revenue drop\" is eventually going to need. Increments, so\n * two partials on one order both land.\n */\n await snapshot.ref\n .set(\n {\n refundedCents: FieldValue.increment(amountCents),\n lastRefundAtMs: Date.now(),\n ...(options.kind === 'chargeback' ? { chargedBack: true } : {}),\n ...(options.closedTheOrder ? { fullyRefunded: true } : {}),\n },\n { merge: true },\n )\n .catch(() => undefined)\n\n return true\n } catch (error) {\n console.error('reverseEmailAttributedRevenue failed', error)\n return false\n }\n}\n\n/** The window, re-exported so a caller needs one import for the whole join. */\nexport { EMAIL_ATTRIBUTION_WINDOW_MS }\n"],"names":["FieldValue","EMAIL_ATTRIBUTION_MODEL","EMAIL_ATTRIBUTION_WINDOW_DAYS","EMAIL_ATTRIBUTION_WINDOW_MS","emailTouchIsInWindow","isDocumentId","firebaseAdmin","resolveCampaignSendRef","readEmailCampaignTouch","defaultFirestore","app","firestore","EMAIL_ATTRIBUTIONS_COLLECTION","CAMPAIGN_REVENUE_REPORT_DOC","DEFAULT_ATTRIBUTION_CURRENCY","currencyKey","raw","code","String","trim","toLowerCase","test","minorUnits","value","Math","round","Number","isFinite","attributeOrderToEmail","options","hostId","orderId","amountCents","orderedAtMs","Date","now","db","rawEmail","email","touch","campaignId","clickedAtMs","record","currency","model","windowDays","hostRef","collection","doc","create","createdAt","serverTimestamp","sendRef","sendId","set","byCurrency","grossCents","increment","orders","updatedAt","merge","error","console","reverseEmailAttributedRevenue","snapshot","get","exists","data","refundedCents","closedTheOrder","refundedOrders","ref","lastRefundAtMs","kind","chargedBack","fullyRefunded","catch","undefined"],"mappings":"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"}
@@ -0,0 +1,34 @@
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 { PluginIndexedRecord, PluginRecordIndex } from '@aglyn/aglyn/plugin-manager/plugin-record-index';
18
+ /** The record kind a campaign's email send is published under. */
19
+ export declare const EMAIL_SEND_RECORD_KIND = "emailSend";
20
+ /**
21
+ * One send as another plugin reads it (AGL-3080): named by the name the team
22
+ * gave it, else its subject, with the site it is sent as and its subject as
23
+ * facts. An unnamed send — no name and no subject — is left out, as the
24
+ * index contract asks.
25
+ */
26
+ export declare function emailSendIndexedRecord(id: string, data: Readonly<Record<string, unknown>> | undefined): PluginIndexedRecord | null;
27
+ /**
28
+ * The organization's email sends, indexed for another plugin (AGL-3080) — a
29
+ * person's timeline naming the campaign email it lists from the delivery
30
+ * log. Sends are the ORGANIZATION's (`orgs/{orgId}/campaigns`), each stamped
31
+ * with the one site it is sent as; asked with a site, the index answers that
32
+ * site's sends only.
33
+ */
34
+ export declare const emailSendRecordIndex: PluginRecordIndex;
@@ -0,0 +1,90 @@
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 { isDocumentId } from "@aglyn/tenant-data-admin/server/document-id";
18
+ import firebaseAdmin from "@aglyn/tenant-data-admin/server/firebase-admin";
19
+ import { resolveOrgIdForHost } from "@aglyn/tenant-data-admin/server/organizations";
20
+ import { FieldPath } from "firebase-admin/firestore";
21
+ import { orgCampaignSendRef } from "./campaign-conversion-attribution.js";
22
+ /** The record kind a campaign's email send is published under. */ export const EMAIL_SEND_RECORD_KIND = 'emailSend';
23
+ /**
24
+ * One send as another plugin reads it (AGL-3080): named by the name the team
25
+ * gave it, else its subject, with the site it is sent as and its subject as
26
+ * facts. An unnamed send — no name and no subject — is left out, as the
27
+ * index contract asks.
28
+ */ export function emailSendIndexedRecord(id, data) {
29
+ if (!id || !data) return null;
30
+ const text = (value)=>typeof value === 'string' ? value.trim() : '';
31
+ const subject = text(data['subject']);
32
+ const name = text(data['displayName']) || subject;
33
+ if (!name) return null;
34
+ const hostId = text(data['hostId']);
35
+ return {
36
+ id,
37
+ name,
38
+ facts: _extends({}, hostId ? {
39
+ hostId
40
+ } : {}, subject ? {
41
+ subject
42
+ } : {}, {
43
+ status: text(data['status']) || null
44
+ })
45
+ };
46
+ }
47
+ /** The organization a scope names, resolving it from the site when only a site is given. */ async function scopeOrg(scope) {
48
+ var _scope_orgId, _scope_hostId;
49
+ const given = String((_scope_orgId = scope.orgId) != null ? _scope_orgId : '');
50
+ if (isDocumentId(given)) return given;
51
+ const hostId = String((_scope_hostId = scope.hostId) != null ? _scope_hostId : '');
52
+ return isDocumentId(hostId) ? await resolveOrgIdForHost(hostId) : null;
53
+ }
54
+ /**
55
+ * The organization's email sends, indexed for another plugin (AGL-3080) — a
56
+ * person's timeline naming the campaign email it lists from the delivery
57
+ * log. Sends are the ORGANIZATION's (`orgs/{orgId}/campaigns`), each stamped
58
+ * with the one site it is sent as; asked with a site, the index answers that
59
+ * site's sends only.
60
+ */ export const emailSendRecordIndex = {
61
+ async list (request) {
62
+ const orgId = await scopeOrg(request);
63
+ if (!orgId) return {
64
+ records: [],
65
+ truncated: false
66
+ };
67
+ const firestore = firebaseAdmin.app().firestore();
68
+ let sends = firestore.collection('orgs').doc(orgId).collection('campaigns');
69
+ if (request.hostId) sends = sends.where('hostId', '==', request.hostId);
70
+ const max = Math.max(1, request.limit);
71
+ const snapshot = await sends.orderBy(FieldPath.documentId()).limit(max + 1).get();
72
+ const records = snapshot.docs.slice(0, max).map((doc)=>emailSendIndexedRecord(doc.id, doc.data())).filter((record)=>record !== null);
73
+ return {
74
+ records,
75
+ truncated: snapshot.size > max
76
+ };
77
+ },
78
+ async get (request) {
79
+ if (!isDocumentId(request.id)) return null;
80
+ const orgId = await scopeOrg(request);
81
+ if (!orgId) return null;
82
+ const snapshot = await orgCampaignSendRef(orgId, request.id).get();
83
+ if (!snapshot.exists) return null;
84
+ const record = emailSendIndexedRecord(snapshot.id, snapshot.data());
85
+ if (record && request.hostId && record.facts['hostId'] !== request.hostId) return null;
86
+ return record;
87
+ }
88
+ };
89
+
90
+ //# sourceMappingURL=email-send-record-index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/email-send-record-index.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 PluginIndexedRecord,\n PluginRecordIndex,\n} from '@aglyn/aglyn/plugin-manager/plugin-record-index'\nimport { isDocumentId } from '@aglyn/tenant-data-admin/server/document-id'\nimport firebaseAdmin from '@aglyn/tenant-data-admin/server/firebase-admin'\nimport { resolveOrgIdForHost } from '@aglyn/tenant-data-admin/server/organizations'\nimport { FieldPath } from 'firebase-admin/firestore'\nimport { orgCampaignSendRef } from './campaign-conversion-attribution'\n\n/** The record kind a campaign's email send is published under. */\nexport const EMAIL_SEND_RECORD_KIND = 'emailSend'\n\n/**\n * One send as another plugin reads it (AGL-3080): named by the name the team\n * gave it, else its subject, with the site it is sent as and its subject as\n * facts. An unnamed send — no name and no subject — is left out, as the\n * index contract asks.\n */\nexport function emailSendIndexedRecord(\n id: string,\n data: Readonly<Record<string, unknown>> | undefined,\n): PluginIndexedRecord | null {\n if (!id || !data) return null\n const text = (value: unknown) => (typeof value === 'string' ? value.trim() : '')\n const subject = text(data['subject'])\n const name = text(data['displayName']) || subject\n if (!name) return null\n const hostId = text(data['hostId'])\n return {\n id,\n name,\n facts: {\n ...(hostId ? { hostId } : {}),\n ...(subject ? { subject } : {}),\n status: text(data['status']) || null,\n },\n }\n}\n\n/** The organization a scope names, resolving it from the site when only a site is given. */\nasync function scopeOrg(scope: { orgId?: string | null; hostId?: string | null }): Promise<string | null> {\n const given = String(scope.orgId ?? '')\n if (isDocumentId(given)) return given\n const hostId = String(scope.hostId ?? '')\n return isDocumentId(hostId) ? await resolveOrgIdForHost(hostId) : null\n}\n\n/**\n * The organization's email sends, indexed for another plugin (AGL-3080) — a\n * person's timeline naming the campaign email it lists from the delivery\n * log. Sends are the ORGANIZATION's (`orgs/{orgId}/campaigns`), each stamped\n * with the one site it is sent as; asked with a site, the index answers that\n * site's sends only.\n */\nexport const emailSendRecordIndex: PluginRecordIndex = {\n async list(request) {\n const orgId = await scopeOrg(request)\n if (!orgId) return { records: [], truncated: false }\n const firestore = firebaseAdmin.app().firestore()\n let sends: FirebaseFirestore.Query = firestore.collection('orgs').doc(orgId).collection('campaigns')\n if (request.hostId) sends = sends.where('hostId', '==', request.hostId)\n const max = Math.max(1, request.limit)\n const snapshot = await sends.orderBy(FieldPath.documentId()).limit(max + 1).get()\n const records = snapshot.docs\n .slice(0, max)\n .map((doc) => emailSendIndexedRecord(doc.id, doc.data()))\n .filter((record): record is PluginIndexedRecord => record !== null)\n return { records, truncated: snapshot.size > max }\n },\n async get(request) {\n if (!isDocumentId(request.id)) return null\n const orgId = await scopeOrg(request)\n if (!orgId) return null\n const snapshot = await orgCampaignSendRef(orgId, request.id).get()\n if (!snapshot.exists) return null\n const record = emailSendIndexedRecord(snapshot.id, snapshot.data())\n if (record && request.hostId && record.facts['hostId'] !== request.hostId) return null\n return record\n },\n}\n"],"names":["isDocumentId","firebaseAdmin","resolveOrgIdForHost","FieldPath","orgCampaignSendRef","EMAIL_SEND_RECORD_KIND","emailSendIndexedRecord","id","data","text","value","trim","subject","name","hostId","facts","status","scopeOrg","scope","given","String","orgId","emailSendRecordIndex","list","request","records","truncated","firestore","app","sends","collection","doc","where","max","Math","limit","snapshot","orderBy","documentId","get","docs","slice","map","filter","record","size","exists"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC;AAMD,SAASA,YAAY,QAAQ,8CAA6C;AAC1E,OAAOC,mBAAmB,iDAAgD;AAC1E,SAASC,mBAAmB,QAAQ,gDAA+C;AACnF,SAASC,SAAS,QAAQ,2BAA0B;AACpD,SAASC,kBAAkB,QAAQ,uCAAmC;AAEtE,gEAAgE,GAChE,OAAO,MAAMC,yBAAyB,YAAW;AAEjD;;;;;CAKC,GACD,OAAO,SAASC,uBACdC,EAAU,EACVC,IAAmD;IAEnD,IAAI,CAACD,MAAM,CAACC,MAAM,OAAO;IACzB,MAAMC,OAAO,CAACC,QAAoB,OAAOA,UAAU,WAAWA,MAAMC,IAAI,KAAK;IAC7E,MAAMC,UAAUH,KAAKD,IAAI,CAAC,UAAU;IACpC,MAAMK,OAAOJ,KAAKD,IAAI,CAAC,cAAc,KAAKI;IAC1C,IAAI,CAACC,MAAM,OAAO;IAClB,MAAMC,SAASL,KAAKD,IAAI,CAAC,SAAS;IAClC,OAAO;QACLD;QACAM;QACAE,OAAO,aACDD,SAAS;YAAEA;QAAO,IAAI,CAAC,GACvBF,UAAU;YAAEA;QAAQ,IAAI,CAAC;YAC7BI,QAAQP,KAAKD,IAAI,CAAC,SAAS,KAAK;;IAEpC;AACF;AAEA,0FAA0F,GAC1F,eAAeS,SAASC,KAAwD;QACzDA,cAECA;IAFtB,MAAMC,QAAQC,QAAOF,eAAAA,MAAMG,KAAK,YAAXH,eAAe;IACpC,IAAIlB,aAAamB,QAAQ,OAAOA;IAChC,MAAML,SAASM,QAAOF,gBAAAA,MAAMJ,MAAM,YAAZI,gBAAgB;IACtC,OAAOlB,aAAac,UAAU,MAAMZ,oBAAoBY,UAAU;AACpE;AAEA;;;;;;CAMC,GACD,OAAO,MAAMQ,uBAA0C;IACrD,MAAMC,MAAKC,OAAO;QAChB,MAAMH,QAAQ,MAAMJ,SAASO;QAC7B,IAAI,CAACH,OAAO,OAAO;YAAEI,SAAS,EAAE;YAAEC,WAAW;QAAM;QACnD,MAAMC,YAAY1B,cAAc2B,GAAG,GAAGD,SAAS;QAC/C,IAAIE,QAAiCF,UAAUG,UAAU,CAAC,QAAQC,GAAG,CAACV,OAAOS,UAAU,CAAC;QACxF,IAAIN,QAAQV,MAAM,EAAEe,QAAQA,MAAMG,KAAK,CAAC,UAAU,MAAMR,QAAQV,MAAM;QACtE,MAAMmB,MAAMC,KAAKD,GAAG,CAAC,GAAGT,QAAQW,KAAK;QACrC,MAAMC,WAAW,MAAMP,MAAMQ,OAAO,CAAClC,UAAUmC,UAAU,IAAIH,KAAK,CAACF,MAAM,GAAGM,GAAG;QAC/E,MAAMd,UAAUW,SAASI,IAAI,CAC1BC,KAAK,CAAC,GAAGR,KACTS,GAAG,CAAC,CAACX,MAAQzB,uBAAuByB,IAAIxB,EAAE,EAAEwB,IAAIvB,IAAI,KACpDmC,MAAM,CAAC,CAACC,SAA0CA,WAAW;QAChE,OAAO;YAAEnB;YAASC,WAAWU,SAASS,IAAI,GAAGZ;QAAI;IACnD;IACA,MAAMM,KAAIf,OAAO;QACf,IAAI,CAACxB,aAAawB,QAAQjB,EAAE,GAAG,OAAO;QACtC,MAAMc,QAAQ,MAAMJ,SAASO;QAC7B,IAAI,CAACH,OAAO,OAAO;QACnB,MAAMe,WAAW,MAAMhC,mBAAmBiB,OAAOG,QAAQjB,EAAE,EAAEgC,GAAG;QAChE,IAAI,CAACH,SAASU,MAAM,EAAE,OAAO;QAC7B,MAAMF,SAAStC,uBAAuB8B,SAAS7B,EAAE,EAAE6B,SAAS5B,IAAI;QAChE,IAAIoC,UAAUpB,QAAQV,MAAM,IAAI8B,OAAO7B,KAAK,CAAC,SAAS,KAAKS,QAAQV,MAAM,EAAE,OAAO;QAClF,OAAO8B;IACT;AACF,EAAC"}
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/get-client-automations.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/server'\nimport { compileClientAutomations, type RawHostAction } from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\n\n/**\n * One of three per-render reads AGL-1302 left uncached (AGL-1440).\n *\n * The automations engine loads on every plan, so this query ran on every render\n * of every path on every site — and an empty result still bills one read. 60s\n * backstop; `revalidateTag(tenant-data:{hostId})` busts it on publish.\n *\n * ONLY the raw documents are cached. The compile below is deliberately outside,\n * and that is the whole safety argument for this cache: `compileClientAutomations`\n * drops the advanced and server steps for orgs without `actions`, and `runJs`\n * for orgs without `webhooks`. Caching a compiled result would pin one org's\n * plan verdict onto the entry — serving a downgraded org paid steps, or a\n * just-upgraded one the trimmed payload, until the tag was busted. The\n * documents are the same for every visitor and every plan; the trim is not.\n */\nconst HOST_ACTIONS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/**\n * A site-event automation prepared for the page runtime (AGL-256): the\n * trigger config the client engine needs, its CLIENT steps, and whether\n * server steps exist (fired actions then dispatch to\n * /api/events/dispatch). Fail-open: errors return an empty list.\n */\nexport type { ClientAutomation } from '../model/site-contract'\nimport type { ClientAutomation } from '../model/site-contract'\n\nexport async function getClientAutomations(options: {\n hostId: string\n /** Leading-slash page path for pathPattern targeting. */\n path: string\n /**\n * The composed nodes of the page being rendered.\n *\n * Interactions authored on an element live ON the node, so they arrive with\n * the document rather than from a second query — which is the point: they\n * publish and roll back with the screen, they travel with a copied\n * component, and they are gone when the element is. The host's `actions`\n * collection keeps carrying genuine site-event automations, and both\n * compile through the same compiler below so the client engine sees one\n * shape.\n *\n * Costs nothing to read: these nodes are already in hand for the render.\n */\n nodes?: Iterable<Aglyn.InteractionNode | null | undefined>\n /**\n * `actions` entitlement (AGL-577). Basic presentational steps\n * (menu/drawer/show-hide/class/nav/alert) always load; the advanced\n * client steps (overlay/showHtml/analytics) and server steps are\n * dropped when this is false. Server steps are re-checked server-side\n * on dispatch, so a false value here only trims the client payload.\n */\n actionsEntitled: boolean\n /** Business-tier gate for `runJs` steps (dropped when false). */\n allowJs: boolean\n /**\n * The page has no request path of its own (AGL-2511), so path-scoped\n * automations are dropped rather than matched. See `dropPathScoped`.\n */\n dropPathScoped?: boolean\n}): Promise<ClientAutomation[]> {\n try {\n const actions = await withRenderCache({\n key: ['marketing-host-actions', options.hostId],\n revalidate: HOST_ACTIONS_TTL_SECONDS,\n tags: [tenantDataTag(options.hostId)],\n read: () => readHostActions(options.hostId),\n })\n /**\n * The document's own interactions, merged before the compile.\n *\n * AFTER the host actions, so an element's own choreography enrols last\n * and a site-wide automation on the same event still runs first — the\n * order a page is read in.\n *\n * Deliberately OUTSIDE the cache above. That entry is keyed on the host\n * and shared by every path on the site; these belong to one page, and\n * putting them in would serve one screen's interactions to another.\n */\n const withNodeInteractions = options.nodes\n ? [...actions, ...Aglyn.collectNodeInteractions(options.nodes)]\n : actions\n // Shared mapping (AGL-830): the same pure compiler the editor Preview\n // uses, so live and preview trim + shape automations identically. Run per\n // render, never cached — see `HOST_ACTIONS_TTL_SECONDS`.\n return compileClientAutomations(withNodeInteractions, {\n path: options.path,\n actionsEntitled: options.actionsEntitled,\n allowJs: options.allowJs,\n ...(options.dropPathScoped ? { dropPathScoped: true } : {}),\n })\n } catch (error) {\n console.error(error)\n return []\n }\n}\n\n/** The host's raw `actions` documents — pure published data, no plan in it. */\nasync function readHostActions(hostId: string): Promise<RawHostAction[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('actions')\n .limit(50)\n .get()\n return snapshot.docs.map((doc) => ({\n id: doc.id,\n action: doc.data() as Aglyn.HostAction,\n }))\n}\n\nexport default getClientAutomations\n"],"names":["Aglyn","compileClientAutomations","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","HOST_ACTIONS_TTL_SECONDS","getClientAutomations","options","actions","key","hostId","revalidate","tags","read","readHostActions","withNodeInteractions","nodes","collectNodeInteractions","path","actionsEntitled","allowJs","dropPathScoped","error","console","snapshot","app","firestore","collection","doc","limit","get","docs","map","id","action","data"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,SAASC,wBAAwB,QAA4B,oBAAU;AACvE,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAE9C;;;;;;;;;;;;;;CAcC,GACD,MAAMC,2BAA2BH;AAWjC,OAAO,eAAeI,qBAAqBC,OAiC1C;IACC,IAAI;QACF,MAAMC,UAAU,MAAMJ,gBAAgB;YACpCK,KAAK;gBAAC;gBAA0BF,QAAQG,MAAM;aAAC;YAC/CC,YAAYN;YACZO,MAAM;gBAACT,cAAcI,QAAQG,MAAM;aAAE;YACrCG,MAAM,IAAMC,gBAAgBP,QAAQG,MAAM;QAC5C;QACA;;;;;;;;;;KAUC,GACD,MAAMK,uBAAuBR,QAAQS,KAAK,GACtC;eAAIR;eAAYT,MAAMkB,uBAAuB,CAACV,QAAQS,KAAK;SAAE,GAC7DR;QACJ,sEAAsE;QACtE,0EAA0E;QAC1E,yDAAyD;QACzD,OAAOR,yBAAyBe,sBAAsB;YACpDG,MAAMX,QAAQW,IAAI;YAClBC,iBAAiBZ,QAAQY,eAAe;YACxCC,SAASb,QAAQa,OAAO;WACpBb,QAAQc,cAAc,GAAG;YAAEA,gBAAgB;QAAK,IAAI,CAAC;IAE7D,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,EAAE;IACX;AACF;AAEA,6EAA6E,GAC7E,eAAeR,gBAAgBJ,MAAc;IAC3C,MAAMc,WAAW,MAAMvB,cACpBwB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAAClB,QACJiB,UAAU,CAAC,WACXE,KAAK,CAAC,IACNC,GAAG;IACN,OAAON,SAASO,IAAI,CAACC,GAAG,CAAC,CAACJ,MAAS,CAAA;YACjCK,IAAIL,IAAIK,EAAE;YACVC,QAAQN,IAAIO,IAAI;QAClB,CAAA;AACF;AAEA,eAAe7B,qBAAoB"}
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/marketing/src/lib/server/get-client-automations.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/server'\nimport { compileClientAutomations, type RawHostAction } from '../model'\nimport { firebaseAdmin } from '@aglyn/tenant-data-admin'\nimport {\n PUBLISHED_SITE_DATA_TTL_SECONDS,\n tenantDataTag,\n withRenderCache,\n} from '@aglyn/tenant-data-admin/render-cache'\n\n/**\n * One of three per-render reads AGL-1302 left uncached (AGL-1440).\n *\n * The automations engine loads on every plan, so this query ran on every render\n * of every path on every site — and an empty result still bills one read. 60s\n * backstop; `revalidateTag(tenant-data:{hostId})` busts it on publish.\n *\n * ONLY the raw documents are cached. The compile below is deliberately outside,\n * and that is the whole safety argument for this cache: `compileClientAutomations`\n * drops the advanced and server steps for orgs without `actions`, and `runJs`\n * for orgs without `webhooks`. Caching a compiled result would pin one org's\n * plan verdict onto the entry — serving a downgraded org paid steps, or a\n * just-upgraded one the trimmed payload, until the tag was busted. The\n * documents are the same for every visitor and every plan; the trim is not.\n */\nconst HOST_ACTIONS_TTL_SECONDS = PUBLISHED_SITE_DATA_TTL_SECONDS\n\n/**\n * A site-event automation prepared for the page runtime (AGL-256): the\n * trigger config the client engine needs, its CLIENT steps, and whether\n * server steps exist (fired actions then dispatch to\n * /api/events/dispatch). Fail-open: errors return an empty list.\n */\nexport type { ClientAutomation } from '../model/site-contract'\nimport type { ClientAutomation } from '../model/site-contract'\n\nexport async function getClientAutomations(options: {\n hostId: string\n /** Leading-slash page path for pathPattern targeting. */\n path: string\n /**\n * The composed nodes of the page being rendered.\n *\n * Interactions authored on an element live ON the node, so they arrive with\n * the document rather than from a second query — which is the point: they\n * publish and roll back with the screen, they travel with a copied\n * component, and they are gone when the element is. The host's `actions`\n * collection keeps carrying genuine site-event automations, and both\n * compile through the same compiler below so the client engine sees one\n * shape.\n *\n * Costs nothing to read: these nodes are already in hand for the render.\n */\n nodes?: Iterable<Aglyn.InteractionNode | null | undefined>\n /**\n * `actions` entitlement (AGL-577). Basic presentational steps\n * (menu/drawer/show-hide/class/nav/alert) always load; the advanced\n * client steps (overlay/showHtml/analytics) and server steps are\n * dropped when this is false. Server steps are re-checked server-side\n * on dispatch, so a false value here only trims the client payload.\n */\n actionsEntitled: boolean\n /** Business-tier gate for `runJs` steps (dropped when false). */\n allowJs: boolean\n /**\n * The page has no request path of its own (AGL-2511), so path-scoped\n * automations are dropped rather than matched. See `dropPathScoped`.\n */\n dropPathScoped?: boolean\n}): Promise<ClientAutomation[]> {\n try {\n const actions = await withRenderCache({\n key: ['marketing-host-actions', options.hostId],\n revalidate: HOST_ACTIONS_TTL_SECONDS,\n tags: [tenantDataTag(options.hostId)],\n read: () => readHostActions(options.hostId),\n })\n /**\n * The document's own interactions, merged before the compile.\n *\n * AFTER the host actions, so an element's own choreography enrols last\n * and a site-wide automation on the same event still runs first — the\n * order a page is read in.\n *\n * Deliberately OUTSIDE the cache above. That entry is keyed on the host\n * and shared by every path on the site; these belong to one page, and\n * putting them in would serve one screen's interactions to another.\n */\n const withNodeInteractions = options.nodes\n ? [...actions, ...Aglyn.collectNodeInteractions(options.nodes)]\n : actions\n // Shared mapping (AGL-830): the same pure compiler the editor Preview\n // uses, so live and preview trim + shape automations identically. Run per\n // render, never cached — see `HOST_ACTIONS_TTL_SECONDS`.\n return compileClientAutomations(withNodeInteractions, {\n path: options.path,\n actionsEntitled: options.actionsEntitled,\n allowJs: options.allowJs,\n ...(options.dropPathScoped ? { dropPathScoped: true } : {}),\n })\n } catch (error) {\n console.error(error)\n return []\n }\n}\n\n/** The host's raw `actions` documents — pure published data, no plan in it. */\nasync function readHostActions(hostId: string): Promise<RawHostAction[]> {\n const snapshot = await firebaseAdmin\n .app()\n .firestore()\n .collection('hosts')\n .doc(hostId)\n .collection('actions')\n .limit(50)\n .get()\n return snapshot.docs.map((doc) => ({\n id: doc.id,\n action: doc.data() as RawHostAction['action'],\n }))\n}\n\nexport default getClientAutomations\n"],"names":["Aglyn","compileClientAutomations","firebaseAdmin","PUBLISHED_SITE_DATA_TTL_SECONDS","tenantDataTag","withRenderCache","HOST_ACTIONS_TTL_SECONDS","getClientAutomations","options","actions","key","hostId","revalidate","tags","read","readHostActions","withNodeInteractions","nodes","collectNodeInteractions","path","actionsEntitled","allowJs","dropPathScoped","error","console","snapshot","app","firestore","collection","doc","limit","get","docs","map","id","action","data"],"mappings":";AAAA;;;;;;;;;;;;;;;CAeC,GAED,YAAYA,WAAW,sBAAqB;AAC5C,SAASC,wBAAwB,QAA4B,oBAAU;AACvE,SAASC,aAAa,QAAQ,2BAA0B;AACxD,SACEC,+BAA+B,EAC/BC,aAAa,EACbC,eAAe,QACV,wCAAuC;AAE9C;;;;;;;;;;;;;;CAcC,GACD,MAAMC,2BAA2BH;AAWjC,OAAO,eAAeI,qBAAqBC,OAiC1C;IACC,IAAI;QACF,MAAMC,UAAU,MAAMJ,gBAAgB;YACpCK,KAAK;gBAAC;gBAA0BF,QAAQG,MAAM;aAAC;YAC/CC,YAAYN;YACZO,MAAM;gBAACT,cAAcI,QAAQG,MAAM;aAAE;YACrCG,MAAM,IAAMC,gBAAgBP,QAAQG,MAAM;QAC5C;QACA;;;;;;;;;;KAUC,GACD,MAAMK,uBAAuBR,QAAQS,KAAK,GACtC;eAAIR;eAAYT,MAAMkB,uBAAuB,CAACV,QAAQS,KAAK;SAAE,GAC7DR;QACJ,sEAAsE;QACtE,0EAA0E;QAC1E,yDAAyD;QACzD,OAAOR,yBAAyBe,sBAAsB;YACpDG,MAAMX,QAAQW,IAAI;YAClBC,iBAAiBZ,QAAQY,eAAe;YACxCC,SAASb,QAAQa,OAAO;WACpBb,QAAQc,cAAc,GAAG;YAAEA,gBAAgB;QAAK,IAAI,CAAC;IAE7D,EAAE,OAAOC,OAAO;QACdC,QAAQD,KAAK,CAACA;QACd,OAAO,EAAE;IACX;AACF;AAEA,6EAA6E,GAC7E,eAAeR,gBAAgBJ,MAAc;IAC3C,MAAMc,WAAW,MAAMvB,cACpBwB,GAAG,GACHC,SAAS,GACTC,UAAU,CAAC,SACXC,GAAG,CAAClB,QACJiB,UAAU,CAAC,WACXE,KAAK,CAAC,IACNC,GAAG;IACN,OAAON,SAASO,IAAI,CAACC,GAAG,CAAC,CAACJ,MAAS,CAAA;YACjCK,IAAIL,IAAIK,EAAE;YACVC,QAAQN,IAAIO,IAAI;QAClB,CAAA;AACF;AAEA,eAAe7B,qBAAoB"}
@@ -14,9 +14,9 @@
14
14
  * See the License for the specific language governing permissions and
15
15
  * limitations under the License.
16
16
  */ import { PLUGIN_FIGURE_MAX_ROWS, pluginFigureWindows, registerPluginFigureReader } from "@aglyn/aglyn/plugin-manager/plugin-figures";
17
- import { campaignReport } from "@aglyn/shared-ui-email-campaigns/model/campaign-report";
18
- import { EMAIL_CREATED_AT_FIELD, emailSendTimeMs } from "@aglyn/shared-ui-email-campaigns/model/email-record";
19
- import { CAMPAIGN_SEND_HOST_FIELD } from "@aglyn/shared-ui-email-campaigns/model/campaign-container";
17
+ import { campaignReport } from "../model/campaign-report.js";
18
+ import { EMAIL_CREATED_AT_FIELD, emailSendTimeMs } from "../model/email-record.js";
19
+ import { CAMPAIGN_SEND_HOST_FIELD } from "../model/campaign-container.js";
20
20
  import { orgCampaignSends } from "./campaign-org-refs.js";
21
21
  import { compareVariants, summarizeVariantStats } from "../model/experiments.js";
22
22
  const MARKETING_PLUGIN_ID = 'marketing';