@aglyn/tenant-data-admin 1.0.0-beta.221 → 1.0.0-beta.222

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/package.json +7 -7
  2. package/src/lib/server/api-keys.js +3 -3
  3. package/src/lib/server/api-keys.js.map +1 -1
  4. package/src/lib/server/api-v1-resources.d.ts +44 -3
  5. package/src/lib/server/api-v1-resources.js +82 -5
  6. package/src/lib/server/api-v1-resources.js.map +1 -1
  7. package/src/lib/server/collection-live-pages.d.ts +2 -2
  8. package/src/lib/server/collection-live-pages.js +1 -1
  9. package/src/lib/server/collection-live-pages.js.map +1 -1
  10. package/src/lib/server/email-delivery-log.d.ts +17 -73
  11. package/src/lib/server/email-delivery-log.js +44 -222
  12. package/src/lib/server/email-delivery-log.js.map +1 -1
  13. package/src/lib/server/email-flow-gate.js +27 -38
  14. package/src/lib/server/email-flow-gate.js.map +1 -1
  15. package/src/lib/server/erase-person.d.ts +34 -54
  16. package/src/lib/server/erase-person.js +44 -239
  17. package/src/lib/server/erase-person.js.map +1 -1
  18. package/src/lib/server/host-visitor-records.d.ts +12 -4
  19. package/src/lib/server/host-visitor-records.js +20 -10
  20. package/src/lib/server/host-visitor-records.js.map +1 -1
  21. package/src/lib/server/hosted-page-review.js +46 -33
  22. package/src/lib/server/hosted-page-review.js.map +1 -1
  23. package/src/lib/server/link-reputation-review.d.ts +6 -2
  24. package/src/lib/server/link-reputation-review.js +61 -20
  25. package/src/lib/server/link-reputation-review.js.map +1 -1
  26. package/src/lib/server/live-page-drops.d.ts +67 -0
  27. package/src/lib/server/live-page-drops.js +57 -0
  28. package/src/lib/server/live-page-drops.js.map +1 -0
  29. package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
  30. package/src/lib/server/media-bandwidth-cap.js +180 -0
  31. package/src/lib/server/media-bandwidth-cap.js.map +1 -0
  32. package/src/lib/server/media-cdn-icon.d.ts +77 -0
  33. package/src/lib/server/media-cdn-icon.js +140 -0
  34. package/src/lib/server/media-cdn-icon.js.map +1 -0
  35. package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
  36. package/src/lib/server/media-delivery-regeneration.js +216 -0
  37. package/src/lib/server/media-delivery-regeneration.js.map +1 -0
  38. package/src/lib/server/media-delivery.d.ts +2 -0
  39. package/src/lib/server/media-delivery.js +2 -1
  40. package/src/lib/server/media-delivery.js.map +1 -1
  41. package/src/lib/server/media-serve-count.d.ts +50 -0
  42. package/src/lib/server/media-serve-count.js +62 -0
  43. package/src/lib/server/media-serve-count.js.map +1 -0
  44. package/src/lib/server/media-signing.d.ts +13 -2
  45. package/src/lib/server/media-signing.js +12 -8
  46. package/src/lib/server/media-signing.js.map +1 -1
  47. package/src/lib/server/media-tombstone.js +7 -5
  48. package/src/lib/server/media-tombstone.js.map +1 -1
  49. package/src/lib/server/media-variants.d.ts +151 -20
  50. package/src/lib/server/media-variants.js +204 -27
  51. package/src/lib/server/media-variants.js.map +1 -1
  52. package/src/lib/server/organizations.d.ts +102 -2
  53. package/src/lib/server/organizations.js +388 -58
  54. package/src/lib/server/organizations.js.map +1 -1
  55. package/src/lib/server/outbound-send-review.js +5 -4
  56. package/src/lib/server/outbound-send-review.js.map +1 -1
  57. package/src/lib/server/paged-sweeps.d.ts +51 -0
  58. package/src/lib/server/paged-sweeps.js +79 -0
  59. package/src/lib/server/paged-sweeps.js.map +1 -0
  60. package/src/lib/server/platform-marketing-consent.d.ts +0 -49
  61. package/src/lib/server/platform-marketing-consent.js +27 -11
  62. package/src/lib/server/platform-marketing-consent.js.map +1 -1
  63. package/src/lib/server/serve-media-cdn.d.ts +69 -1
  64. package/src/lib/server/serve-media-cdn.js +424 -70
  65. package/src/lib/server/serve-media-cdn.js.map +1 -1
  66. package/src/lib/server/site-icon-render.d.ts +68 -0
  67. package/src/lib/server/site-icon-render.js +157 -0
  68. package/src/lib/server/site-icon-render.js.map +1 -0
  69. package/src/lib/server/staff-seat.d.ts +50 -0
  70. package/src/lib/server/staff-seat.js +118 -0
  71. package/src/lib/server/staff-seat.js.map +1 -0
  72. package/src/lib/server/transfer-export.d.ts +46 -0
  73. package/src/lib/server/transfer-export.js +221 -0
  74. package/src/lib/server/transfer-export.js.map +1 -0
  75. package/src/lib/server/transfer-jobs.d.ts +385 -0
  76. package/src/lib/server/transfer-jobs.js +2368 -0
  77. package/src/lib/server/transfer-jobs.js.map +1 -0
  78. package/src/lib/server/transfer-packages.d.ts +127 -0
  79. package/src/lib/server/transfer-packages.js +1036 -0
  80. package/src/lib/server/transfer-packages.js.map +1 -0
  81. package/src/lib/server/upgrade-proposal.d.ts +97 -0
  82. package/src/lib/server/upgrade-proposal.js +312 -0
  83. package/src/lib/server/upgrade-proposal.js.map +1 -0
  84. package/src/lib/server/upsert-contact.d.ts +17 -8
  85. package/src/lib/server/upsert-contact.js +82 -16
  86. package/src/lib/server/upsert-contact.js.map +1 -1
  87. package/src/lib/server/web-risk.d.ts +48 -9
  88. package/src/lib/server/web-risk.js +281 -143
  89. package/src/lib/server/web-risk.js.map +1 -1
  90. package/src/tenancy.d.ts +3 -1
  91. package/src/tenancy.js +5 -1
  92. package/src/tenancy.js.map +1 -1
  93. package/src/lib/server/campaign-attribution-store.d.ts +0 -55
  94. package/src/lib/server/campaign-attribution-store.js +0 -95
  95. package/src/lib/server/campaign-attribution-store.js.map +0 -1
  96. package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
  97. package/src/lib/server/campaign-conversion-attribution.js +0 -450
  98. package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
  99. package/src/lib/server/dataset-live-pages.d.ts +0 -179
  100. package/src/lib/server/dataset-live-pages.js +0 -421
  101. package/src/lib/server/dataset-live-pages.js.map +0 -1
  102. package/src/lib/server/email-revenue-attribution.d.ts +0 -185
  103. package/src/lib/server/email-revenue-attribution.js +0 -321
  104. package/src/lib/server/email-revenue-attribution.js.map +0 -1
@@ -217,62 +217,6 @@ export declare function readPersonEngagement(email: string | null | undefined, f
217
217
  * same reason {@link readPersonEngagement} does.
218
218
  */
219
219
  export declare function readPersonEngagementByKeys(keys: readonly string[], firestore?: any): Promise<Map<string, EmailPersonEngagement>>;
220
- /** The field on `emailDeliveries/{key}` holding the per-host touches. */
221
- export declare const EMAIL_TOUCH_FIELD = "campaignTouches";
222
- /**
223
- * How many sites' touches one person's document keeps.
224
- *
225
- * A cap, not a page size: the map lives in a document with a 1 MiB ceiling
226
- * and nothing else bounds how many sites may mail one address.
227
- */
228
- export declare const EMAIL_TOUCH_MAX_HOSTS = 10;
229
- /**
230
- * The last campaign one person clicked on one site.
231
- *
232
- * A click on a SEQUENCE email (AGL-3254) is the same touch with two more
233
- * facts: the sequence and the enrollment the email went out under. The
234
- * campaign is then the container the sequence is in, and the identify
235
- * moments this touch is credited to read as the sequence's rather than as
236
- * a campaign send's.
237
- */
238
- export interface EmailCampaignTouch {
239
- hostId: string;
240
- campaignId: string;
241
- /** When the click happened, epoch ms — the provider's instant. */
242
- clickedAtMs: number;
243
- sequenceId?: string;
244
- enrollmentId?: string;
245
- }
246
- /**
247
- * Records that this person clicked this campaign's mail. Never throws.
248
- *
249
- * Forward-only, in a transaction, for the reason {@link recordPersonEngagement}
250
- * is: provider delivery is at-least-once and unordered, so a replayed click
251
- * from last month must not displace this week's. That same property is what
252
- * makes this idempotent — a redelivered event finds its own instant already
253
- * stored and writes nothing.
254
- *
255
- * @returns whether the touch moved forward.
256
- */
257
- export declare function recordEmailCampaignTouch(touch: {
258
- email: string | null | undefined;
259
- hostId: string;
260
- campaignId: string;
261
- atMs: number;
262
- /** Both or neither: a sequence click names the enrollment it came through. */
263
- sequenceId?: string;
264
- enrollmentId?: string;
265
- }, firestore?: any): Promise<boolean>;
266
- /**
267
- * The last campaign this person clicked on this site, or `null`.
268
- *
269
- * One keyed document read — no query, no index, and nothing that can be
270
- * truncated. `null` for an address we hold no touch for AND for a read that
271
- * failed, which are the same answer on purpose: both mean "we cannot say
272
- * which campaign preceded this order", and the only safe thing to do with
273
- * that is credit nobody.
274
- */
275
- export declare function readEmailCampaignTouch(email: string | null | undefined, hostId: string, firestore?: any): Promise<EmailCampaignTouch | null>;
276
220
  /** What one {@link importEmailDeliveryHistory} run did. */
277
221
  export interface EmailDeliveryImportResult {
278
222
  /** Provider messages read. */
@@ -352,21 +296,21 @@ export declare function readEmailDeliveryHistory(email: string | null | undefine
352
296
  lookupFailed: boolean;
353
297
  rows: EmailDeliveryRecord[];
354
298
  }>;
355
- /** The most recipient rows one campaign-engagement read returns. */
356
- export declare const EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE = 25;
299
+ /** The most recipient rows one send-engagement read returns. */
300
+ export declare const EMAIL_SEND_ENGAGEMENT_PAGE_SIZE = 25;
357
301
  /**
358
- * How many campaigns one engagement read can span.
302
+ * How many sends one engagement read can span.
359
303
  *
360
304
  * Firestore's `in` operator takes at most 30 values, and the query below runs
361
305
  * as a merge of one sub-query per value — so this is a hard limit of the
362
- * store rather than a number worth tuning. A design used by more campaigns
363
- * than this reads its most recent 30, and the caller is told so.
306
+ * store rather than a number worth tuning. A design used by more sends than
307
+ * this reads its most recent 30, and the caller is told so.
364
308
  */
365
- export declare const EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS = 30;
366
- /** Which recipients a campaign-engagement read returns. */
309
+ export declare const EMAIL_SEND_ENGAGEMENT_MAX_SENDS = 30;
310
+ /** Which recipients a send-engagement read returns. */
367
311
  export type EmailEngagementFilter = 'all' | 'opened' | 'clicked';
368
312
  /** One page of recipient rows. */
369
- export interface EmailCampaignEngagementPage {
313
+ export interface EmailSendEngagementPage {
370
314
  rows: EmailDeliveryRecord[];
371
315
  /**
372
316
  * Cursor for the next page, or null at the end.
@@ -381,11 +325,11 @@ export interface EmailCampaignEngagementPage {
381
325
  cursor: string | null;
382
326
  /** The read failed, as distinct from finding nothing. */
383
327
  lookupFailed: boolean;
384
- /** Campaigns past {@link EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS}. */
385
- campaignsOmitted: number;
328
+ /** Sends past {@link EMAIL_SEND_ENGAGEMENT_MAX_SENDS}. */
329
+ sendsOmitted: number;
386
330
  }
387
331
  /**
388
- * The recipients of one site's campaigns, newest message first.
332
+ * The recipients of one site's bulk sends, newest message first.
389
333
  *
390
334
  * ## What each filter orders on, and why it is not one query with a flag
391
335
  *
@@ -401,21 +345,21 @@ export interface EmailCampaignEngagementPage {
401
345
  * ## Never throws
402
346
  *
403
347
  * Same contract as the rest of this file: `lookupFailed` distinguishes a read
404
- * that could not run — a missing index is the likely one — from a campaign
348
+ * that could not run — a missing index is the likely one — from a send
405
349
  * nobody opened. Rendering those two the same way is how a merchant concludes
406
- * their campaign reached nobody.
350
+ * their mail reached nobody.
407
351
  */
408
- export declare function readCampaignEngagement(options: {
352
+ export declare function readSendEngagement(options: {
409
353
  /** The site whose mail this is. The caller must already have proven it. */
410
354
  hostId: string;
411
- /** Campaign ids to read, most recent first. */
412
- campaignIds: readonly string[];
355
+ /** Send ids to read, most recent first. */
356
+ sendIds: readonly string[];
413
357
  filter?: EmailEngagementFilter;
414
358
  limit?: number;
415
359
  /** A `cursor` from a previous page. */
416
360
  cursor?: string | null;
417
361
  firestore?: any;
418
- }): Promise<EmailCampaignEngagementPage>;
362
+ }): Promise<EmailSendEngagementPage>;
419
363
  /**
420
364
  * A record that delivery data WAS held for an address and has been erased.
421
365
  *
@@ -72,7 +72,7 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
72
72
  * a thrown one loses the delivery event AND teaches the provider to retry.
73
73
  */ import { FieldValue } from "firebase-admin/firestore";
74
74
  import { worstDeliveryStatus } from "@aglyn/shared-util-email";
75
- import { eraseCampaignAttributionsForPersonKey } from "./campaign-attribution-store.js";
75
+ import { eraseConversionCredits } from "@aglyn/aglyn/plugin-manager/plugin-conversion-credit";
76
76
  import { nameSearchTokens } from "@aglyn/aglyn/app-utils/name-search";
77
77
  import { emailSearchTokens, emailSuppressionKey } from "./email-suppression.js";
78
78
  import firebaseAdmin from "./firebase-admin.js";
@@ -505,181 +505,6 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
505
505
  }
506
506
  return found;
507
507
  }
508
- /*==========================================
509
- * THE CAMPAIGN TOUCH — which campaign this person last CLICKED, per site.
510
- *
511
- * The engagement rollup above answers "is this person still listening". It
512
- * cannot answer "which email brought them here", because it keeps instants
513
- * and not identities, and that second question is what revenue attribution
514
- * is: an order arrives, and something has to say which campaign preceded it.
515
- *
516
- * ## Here, on the person's own document
517
- *
518
- * The alternative was a per-host collection of touch documents, and it fails
519
- * on erasure. `eraseEmailDeliveriesForAddresses` erases by ADDRESS and knows
520
- * nothing about which sites have mailed it, so a per-host collection would be
521
- * a record of a person's clicks that an erasure request could not reach. On
522
- * the person document it is one field, deleted with the stamps it belongs
523
- * beside — a click is the same personal fact as the open recorded next to it.
524
- *
525
- * ## A CLICK ONLY
526
- *
527
- * `ENGAGEMENT_TYPES` includes opens because the control it feeds REFUSES to
528
- * mail people, and the generous signal is the correct one for a refusal. This
529
- * is the opposite kind of decision — it CREDITS a campaign with money — so it
530
- * takes the strict signal. Since Apple's Mail Privacy Protection an open is
531
- * substantially a statement about the recipient's mail client, and crediting
532
- * revenue to one would credit whichever campaign most recently reached an
533
- * Apple Mail user with orders from people who never read it.
534
- *
535
- * ## Per host, and capped
536
- *
537
- * A single global touch would credit site A's campaign with site B's order,
538
- * or refuse both — the send path refuses cross-site reach and the revenue
539
- * join has to agree with it. So the field is a map keyed by host, and a map
540
- * on a document has to be bounded: past {@link EMAIL_TOUCH_MAX_HOSTS} the
541
- * oldest touch is evicted, inside the transaction the forward-only rule
542
- * already pays for. A person who clicks mail from eleven different sites
543
- * loses their oldest click, which costs an attribution rather than a fact
544
- * anybody else reads.
545
- *=========================================*/ /** The field on `emailDeliveries/{key}` holding the per-host touches. */ export const EMAIL_TOUCH_FIELD = 'campaignTouches';
546
- /**
547
- * How many sites' touches one person's document keeps.
548
- *
549
- * A cap, not a page size: the map lives in a document with a 1 MiB ceiling
550
- * and nothing else bounds how many sites may mail one address.
551
- */ export const EMAIL_TOUCH_MAX_HOSTS = 10;
552
- /** Reads the touch map off a person document's data, defensively. */ function touchesFrom(data) {
553
- const raw = data == null ? void 0 : data[EMAIL_TOUCH_FIELD];
554
- if (!raw || typeof raw !== 'object') return {};
555
- const found = {};
556
- for (const [hostId, entry] of Object.entries(raw)){
557
- var _ref, _ref1, _ref2, _ref3;
558
- const campaignId = String((_ref = entry == null ? void 0 : entry.campaignId) != null ? _ref : '');
559
- const atMs = Number((_ref1 = entry == null ? void 0 : entry.atMs) != null ? _ref1 : 0);
560
- if (!campaignId || !Number.isFinite(atMs) || atMs <= 0) continue;
561
- const sequenceId = String((_ref2 = entry == null ? void 0 : entry.sequenceId) != null ? _ref2 : '');
562
- const enrollmentId = String((_ref3 = entry == null ? void 0 : entry.enrollmentId) != null ? _ref3 : '');
563
- found[hostId] = _extends({
564
- campaignId,
565
- atMs
566
- }, sequenceId && enrollmentId ? {
567
- sequenceId,
568
- enrollmentId
569
- } : {});
570
- }
571
- return found;
572
- }
573
- /**
574
- * Records that this person clicked this campaign's mail. Never throws.
575
- *
576
- * Forward-only, in a transaction, for the reason {@link recordPersonEngagement}
577
- * is: provider delivery is at-least-once and unordered, so a replayed click
578
- * from last month must not displace this week's. That same property is what
579
- * makes this idempotent — a redelivered event finds its own instant already
580
- * stored and writes nothing.
581
- *
582
- * @returns whether the touch moved forward.
583
- */ export async function recordEmailCampaignTouch(touch, firestore) {
584
- var _touch_hostId, _touch_campaignId, _touch_sequenceId, _touch_enrollmentId;
585
- const key = emailSuppressionKey(touch.email);
586
- const hostId = String((_touch_hostId = touch.hostId) != null ? _touch_hostId : '');
587
- const campaignId = String((_touch_campaignId = touch.campaignId) != null ? _touch_campaignId : '');
588
- const atMs = Number(touch.atMs);
589
- if (!key || !hostId || !campaignId) return false;
590
- if (!Number.isFinite(atMs) || atMs <= 0) return false;
591
- const sequenceId = String((_touch_sequenceId = touch.sequenceId) != null ? _touch_sequenceId : '');
592
- const enrollmentId = String((_touch_enrollmentId = touch.enrollmentId) != null ? _touch_enrollmentId : '');
593
- const viaSequence = sequenceId && enrollmentId ? {
594
- sequenceId,
595
- enrollmentId
596
- } : {};
597
- try {
598
- const db = firestore != null ? firestore : defaultFirestore();
599
- const ref = db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key);
600
- let moved = false;
601
- await db.runTransaction(async (transaction)=>{
602
- var _ref;
603
- moved = false;
604
- const snapshot = await transaction.get(ref);
605
- const stored = touchesFrom((_ref = snapshot.exists ? snapshot.data() : null) != null ? _ref : {});
606
- const held = stored[hostId];
607
- // Not newer than what is already there, so nothing is written. An
608
- // out-of-order or replayed event is the ordinary case this skips.
609
- if (held && held.atMs >= atMs) return;
610
- /*
611
- * A merge-set merges nested maps at depth, so a campaign click after a
612
- * sequence click would keep the sequence's ids beside the new campaign
613
- * unless they are deleted by name: the two are written as the value
614
- * or as `FieldValue.delete()` whenever the held entry carried them.
615
- */ const dropSequence = (held == null ? void 0 : held.sequenceId) && !('sequenceId' in viaSequence) ? {
616
- sequenceId: FieldValue.delete(),
617
- enrollmentId: FieldValue.delete()
618
- } : {};
619
- const update = {
620
- [hostId]: _extends({
621
- campaignId,
622
- atMs
623
- }, viaSequence, dropSequence)
624
- };
625
- /*
626
- * EVICTION, and only when this host is NEW to the map. Replacing an
627
- * existing host's touch cannot grow it, so the cap is checked exactly
628
- * where the map can cross it. The oldest goes, because the window makes
629
- * an old touch the one least likely to be credited with anything.
630
- *
631
- * `FieldValue.delete()` INSIDE the map: a merge-set merges nested maps
632
- * at depth, which is what keeps every other host's touch — and is also
633
- * why an evicted key has to be deleted explicitly rather than by
634
- * omission.
635
- */ if (!held && Object.keys(stored).length >= EMAIL_TOUCH_MAX_HOSTS) {
636
- const oldest = Object.entries(stored).sort((a, b)=>a[1].atMs - b[1].atMs || a[0].localeCompare(b[0]))[0];
637
- if (oldest) update[oldest[0]] = FieldValue.delete();
638
- }
639
- transaction.set(ref, {
640
- [EMAIL_TOUCH_FIELD]: update,
641
- updatedAt: FieldValue.serverTimestamp()
642
- }, {
643
- merge: true
644
- });
645
- moved = true;
646
- });
647
- return moved;
648
- } catch (error) {
649
- console.error('[email-delivery-log] campaign touch write failed', error);
650
- return false;
651
- }
652
- }
653
- /**
654
- * The last campaign this person clicked on this site, or `null`.
655
- *
656
- * One keyed document read — no query, no index, and nothing that can be
657
- * truncated. `null` for an address we hold no touch for AND for a read that
658
- * failed, which are the same answer on purpose: both mean "we cannot say
659
- * which campaign preceded this order", and the only safe thing to do with
660
- * that is credit nobody.
661
- */ export async function readEmailCampaignTouch(email, hostId, firestore) {
662
- const key = emailSuppressionKey(email);
663
- if (!key || !hostId) return null;
664
- try {
665
- var _snapshot_data;
666
- const db = firestore != null ? firestore : defaultFirestore();
667
- const snapshot = await db.collection(EMAIL_DELIVERIES_COLLECTION).doc(key).get();
668
- const held = touchesFrom((_snapshot_data = snapshot.data()) != null ? _snapshot_data : {})[hostId];
669
- if (!held) return null;
670
- return _extends({
671
- hostId,
672
- campaignId: held.campaignId,
673
- clickedAtMs: held.atMs
674
- }, held.sequenceId && held.enrollmentId ? {
675
- sequenceId: held.sequenceId,
676
- enrollmentId: held.enrollmentId
677
- } : {});
678
- } catch (error) {
679
- console.error('[email-delivery-log] campaign touch read failed', error);
680
- return null;
681
- }
682
- }
683
508
  /** Default page budget for one import run. 100 messages per page. */ export const EMAIL_DELIVERY_IMPORT_MAX_PAGES = 20;
684
509
  /**
685
510
  * Imports already-sent mail from a provider into the log.
@@ -797,34 +622,37 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
797
622
  }
798
623
  }
799
624
  /*==========================================
800
- * ACROSS THE CAMPAIGNS OF ONE SITE.
625
+ * ACROSS THE BULK SENDS OF ONE SITE.
801
626
  *
802
627
  * The readers above answer "what did we send this person". This one answers
803
- * the other direction — "who did this campaign reach, and which of them
804
- * opened it" — and it is the SAME store, queried across the recipient
805
- * documents instead of down one of them.
628
+ * the other direction — "who did this send reach, and which of them opened
629
+ * it" — and it is the SAME store, queried across the recipient documents
630
+ * instead of down one of them. A send is whatever the sender tagged its
631
+ * messages with when they went out: the mail rail stamps every bulk message
632
+ * with the site and the send's id (`send-email.ts`), under the field name
633
+ * `campaignId` it has always been stored as, whichever plugin sent it.
806
634
  *
807
635
  * That direction is a collection-group query, and it is the one shape this
808
636
  * file's header says the per-address layout avoids. It is worth the index
809
637
  * here for the reason the index exists at all: the alternative is a second
810
- * per-recipient store keyed by campaign, written by the same webhook, which
638
+ * per-recipient store keyed by send, written by the same webhook, which
811
639
  * would be two records of the same fact and one of them eventually wrong.
812
640
  *
813
641
  * ⚠️ EVERY caller must be authorised on `hostId` before calling. The rows
814
642
  * carry recipient addresses, and the `hostId` filter below is a query
815
643
  * predicate, not a permission — it narrows the read to one site's mail and
816
644
  * says nothing about who is asking.
817
- *=========================================*/ /** The most recipient rows one campaign-engagement read returns. */ export const EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE = 25;
645
+ *=========================================*/ /** The most recipient rows one send-engagement read returns. */ export const EMAIL_SEND_ENGAGEMENT_PAGE_SIZE = 25;
818
646
  /**
819
- * How many campaigns one engagement read can span.
647
+ * How many sends one engagement read can span.
820
648
  *
821
649
  * Firestore's `in` operator takes at most 30 values, and the query below runs
822
650
  * as a merge of one sub-query per value — so this is a hard limit of the
823
- * store rather than a number worth tuning. A design used by more campaigns
824
- * than this reads its most recent 30, and the caller is told so.
825
- */ export const EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS = 30;
651
+ * store rather than a number worth tuning. A design used by more sends than
652
+ * this reads its most recent 30, and the caller is told so.
653
+ */ export const EMAIL_SEND_ENGAGEMENT_MAX_SENDS = 30;
826
654
  /**
827
- * The recipients of one site's campaigns, newest message first.
655
+ * The recipients of one site's bulk sends, newest message first.
828
656
  *
829
657
  * ## What each filter orders on, and why it is not one query with a flag
830
658
  *
@@ -840,27 +668,28 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
840
668
  * ## Never throws
841
669
  *
842
670
  * Same contract as the rest of this file: `lookupFailed` distinguishes a read
843
- * that could not run — a missing index is the likely one — from a campaign
671
+ * that could not run — a missing index is the likely one — from a send
844
672
  * nobody opened. Rendering those two the same way is how a merchant concludes
845
- * their campaign reached nobody.
846
- */ export async function readCampaignEngagement(options) {
673
+ * their mail reached nobody.
674
+ */ export async function readSendEngagement(options) {
847
675
  var _options_limit;
848
- const { hostId, campaignIds, filter = 'all', cursor = null, firestore } = options;
849
- const pageSize = Math.max(1, Math.min(EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE, (_options_limit = options.limit) != null ? _options_limit : EMAIL_CAMPAIGN_ENGAGEMENT_PAGE_SIZE));
850
- const ids = campaignIds.filter(Boolean).slice(0, EMAIL_CAMPAIGN_ENGAGEMENT_MAX_CAMPAIGNS);
851
- const campaignsOmitted = Math.max(0, campaignIds.filter(Boolean).length - ids.length);
676
+ const { hostId, sendIds, filter = 'all', cursor = null, firestore } = options;
677
+ const pageSize = Math.max(1, Math.min(EMAIL_SEND_ENGAGEMENT_PAGE_SIZE, (_options_limit = options.limit) != null ? _options_limit : EMAIL_SEND_ENGAGEMENT_PAGE_SIZE));
678
+ const ids = sendIds.filter(Boolean).slice(0, EMAIL_SEND_ENGAGEMENT_MAX_SENDS);
679
+ const sendsOmitted = Math.max(0, sendIds.filter(Boolean).length - ids.length);
852
680
  const empty = {
853
681
  rows: [],
854
682
  cursor: null,
855
683
  lookupFailed: false,
856
- campaignsOmitted
684
+ sendsOmitted
857
685
  };
858
686
  if (!hostId || !ids.length) return empty;
859
687
  try {
860
688
  const db = firestore != null ? firestore : defaultFirestore();
861
689
  let query = db.collectionGroup(EMAIL_DELIVERY_MESSAGES_COLLECTION)// `hostId` first so the read is provably one site's mail even if a
862
- // caller ever passes a campaign id belonging to another.
863
- .where('hostId', '==', hostId).where('campaignId', 'in', ids);
690
+ // caller ever passes a send id belonging to another.
691
+ .where('hostId', '==', hostId)// The send tag's stored name (see the section header).
692
+ .where('campaignId', 'in', ids);
864
693
  if (filter === 'opened') {
865
694
  query = query.where('openCount', '>', 0).orderBy('openCount', 'desc');
866
695
  } else if (filter === 'clicked') {
@@ -884,10 +713,10 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
884
713
  // finished table look unfinished.
885
714
  cursor: rows.length === pageSize ? String(snapshot.docs[snapshot.docs.length - 1].ref.path) : null,
886
715
  lookupFailed: false,
887
- campaignsOmitted
716
+ sendsOmitted
888
717
  };
889
718
  } catch (error) {
890
- console.error('[email-delivery-log] campaign engagement read failed', error);
719
+ console.error('[email-delivery-log] send engagement read failed', error);
891
720
  return _extends({}, empty, {
892
721
  lookupFailed: true
893
722
  });
@@ -1037,16 +866,6 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
1037
866
  */ lastEngagedAtMs: FieldValue.delete(),
1038
867
  lastOpenedAtMs: FieldValue.delete(),
1039
868
  lastClickedAtMs: FieldValue.delete(),
1040
- /*
1041
- * And the campaign touches, for the same reason and one step
1042
- * further: "this person clicked THIS campaign on the 3rd" names
1043
- * both the person and what they were reading, so it is the
1044
- * strongest personal fact on the document. The orders it has
1045
- * already been credited with keep their own record — that one is
1046
- * a commercial fact about a sale, held under the order's id
1047
- * rather than the person's — but nothing here may go on
1048
- * attributing their FUTURE orders to mail they asked us to forget.
1049
- */ [EMAIL_TOUCH_FIELD]: FieldValue.delete(),
1050
869
  updatedAt: FieldValue.serverTimestamp()
1051
870
  }, {
1052
871
  merge: true
@@ -1055,21 +874,24 @@ export const EMAIL_DELIVERY_MESSAGES_COLLECTION = 'messages';
1055
874
  console.error('[email-delivery-log] tombstone write failed', error);
1056
875
  }
1057
876
  /*
1058
- * And the CONCLUSIONS drawn from those touches, on every site.
877
+ * And what was CONCLUDED from this person's clicks, on every site.
1059
878
  *
1060
- * A conversion attribution says "this person came from that campaign and
1061
- * then submitted this form / became this lead / made this booking". It is
1062
- * derived from the click stamp deleted a few lines above and is a
1063
- * strictly stronger statement than the stamp was, so deleting the stamp
1064
- * and keeping the attribution would be an erasure that removed the
1065
- * evidence and kept the conclusion.
879
+ * The plugin that credits outcomes (`plugin-conversion-credit.ts`) keeps
880
+ * which campaign a person last clicked, on this same document, and which
881
+ * campaign each of their sign-ups, leads and bookings was credited to —
882
+ * "this person clicked THIS campaign on the 3rd" names both the person
883
+ * and what they were reading, the strongest personal fact here, and a
884
+ * credit drawn from it is a stronger statement still. Deleting the
885
+ * engagement above and keeping either would be an erasure that removed
886
+ * the evidence and kept the conclusion. The orders already credited keep
887
+ * their own record — a commercial fact about a sale, held under the
888
+ * order's id rather than the person's.
1066
889
  *
1067
- * Keyed on `personKey`, which is `emailSuppressionKey` — the same
1068
- * derivation, one function — so the sweep covers exactly the person this
1069
- * loop is erasing. Per address rather than per host, because an erasure
1070
- * request names an address and knows nothing about which sites it ever
1071
- * visited.
1072
- */ await eraseCampaignAttributionsForPersonKey(key, db);
890
+ * Asked by the same key — `personKey` is `emailSuppressionKey`, one
891
+ * derivation — so it covers exactly the person this loop is erasing, and
892
+ * per address rather than per site, because an erasure request names an
893
+ * address and knows nothing about which sites it ever visited.
894
+ */ await eraseConversionCredits(key);
1073
895
  }
1074
896
  return {
1075
897
  removed,