@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.
- package/package.json +7 -7
- package/src/lib/server/api-keys.js +3 -3
- package/src/lib/server/api-keys.js.map +1 -1
- package/src/lib/server/api-v1-resources.d.ts +44 -3
- package/src/lib/server/api-v1-resources.js +82 -5
- package/src/lib/server/api-v1-resources.js.map +1 -1
- package/src/lib/server/collection-live-pages.d.ts +2 -2
- package/src/lib/server/collection-live-pages.js +1 -1
- package/src/lib/server/collection-live-pages.js.map +1 -1
- package/src/lib/server/email-delivery-log.d.ts +17 -73
- package/src/lib/server/email-delivery-log.js +44 -222
- package/src/lib/server/email-delivery-log.js.map +1 -1
- package/src/lib/server/email-flow-gate.js +27 -38
- package/src/lib/server/email-flow-gate.js.map +1 -1
- package/src/lib/server/erase-person.d.ts +34 -54
- package/src/lib/server/erase-person.js +44 -239
- package/src/lib/server/erase-person.js.map +1 -1
- package/src/lib/server/host-visitor-records.d.ts +12 -4
- package/src/lib/server/host-visitor-records.js +20 -10
- package/src/lib/server/host-visitor-records.js.map +1 -1
- package/src/lib/server/hosted-page-review.js +46 -33
- package/src/lib/server/hosted-page-review.js.map +1 -1
- package/src/lib/server/link-reputation-review.d.ts +6 -2
- package/src/lib/server/link-reputation-review.js +61 -20
- package/src/lib/server/link-reputation-review.js.map +1 -1
- package/src/lib/server/live-page-drops.d.ts +67 -0
- package/src/lib/server/live-page-drops.js +57 -0
- package/src/lib/server/live-page-drops.js.map +1 -0
- package/src/lib/server/media-bandwidth-cap.d.ts +78 -0
- package/src/lib/server/media-bandwidth-cap.js +180 -0
- package/src/lib/server/media-bandwidth-cap.js.map +1 -0
- package/src/lib/server/media-cdn-icon.d.ts +77 -0
- package/src/lib/server/media-cdn-icon.js +140 -0
- package/src/lib/server/media-cdn-icon.js.map +1 -0
- package/src/lib/server/media-delivery-regeneration.d.ts +132 -0
- package/src/lib/server/media-delivery-regeneration.js +216 -0
- package/src/lib/server/media-delivery-regeneration.js.map +1 -0
- package/src/lib/server/media-delivery.d.ts +2 -0
- package/src/lib/server/media-delivery.js +2 -1
- package/src/lib/server/media-delivery.js.map +1 -1
- package/src/lib/server/media-serve-count.d.ts +50 -0
- package/src/lib/server/media-serve-count.js +62 -0
- package/src/lib/server/media-serve-count.js.map +1 -0
- package/src/lib/server/media-signing.d.ts +13 -2
- package/src/lib/server/media-signing.js +12 -8
- package/src/lib/server/media-signing.js.map +1 -1
- package/src/lib/server/media-tombstone.js +7 -5
- package/src/lib/server/media-tombstone.js.map +1 -1
- package/src/lib/server/media-variants.d.ts +151 -20
- package/src/lib/server/media-variants.js +204 -27
- package/src/lib/server/media-variants.js.map +1 -1
- package/src/lib/server/organizations.d.ts +102 -2
- package/src/lib/server/organizations.js +388 -58
- package/src/lib/server/organizations.js.map +1 -1
- package/src/lib/server/outbound-send-review.js +5 -4
- package/src/lib/server/outbound-send-review.js.map +1 -1
- package/src/lib/server/paged-sweeps.d.ts +51 -0
- package/src/lib/server/paged-sweeps.js +79 -0
- package/src/lib/server/paged-sweeps.js.map +1 -0
- package/src/lib/server/platform-marketing-consent.d.ts +0 -49
- package/src/lib/server/platform-marketing-consent.js +27 -11
- package/src/lib/server/platform-marketing-consent.js.map +1 -1
- package/src/lib/server/serve-media-cdn.d.ts +69 -1
- package/src/lib/server/serve-media-cdn.js +424 -70
- package/src/lib/server/serve-media-cdn.js.map +1 -1
- package/src/lib/server/site-icon-render.d.ts +68 -0
- package/src/lib/server/site-icon-render.js +157 -0
- package/src/lib/server/site-icon-render.js.map +1 -0
- package/src/lib/server/staff-seat.d.ts +50 -0
- package/src/lib/server/staff-seat.js +118 -0
- package/src/lib/server/staff-seat.js.map +1 -0
- package/src/lib/server/transfer-export.d.ts +46 -0
- package/src/lib/server/transfer-export.js +221 -0
- package/src/lib/server/transfer-export.js.map +1 -0
- package/src/lib/server/transfer-jobs.d.ts +385 -0
- package/src/lib/server/transfer-jobs.js +2368 -0
- package/src/lib/server/transfer-jobs.js.map +1 -0
- package/src/lib/server/transfer-packages.d.ts +127 -0
- package/src/lib/server/transfer-packages.js +1036 -0
- package/src/lib/server/transfer-packages.js.map +1 -0
- package/src/lib/server/upgrade-proposal.d.ts +97 -0
- package/src/lib/server/upgrade-proposal.js +312 -0
- package/src/lib/server/upgrade-proposal.js.map +1 -0
- package/src/lib/server/upsert-contact.d.ts +17 -8
- package/src/lib/server/upsert-contact.js +82 -16
- package/src/lib/server/upsert-contact.js.map +1 -1
- package/src/lib/server/web-risk.d.ts +48 -9
- package/src/lib/server/web-risk.js +281 -143
- package/src/lib/server/web-risk.js.map +1 -1
- package/src/tenancy.d.ts +3 -1
- package/src/tenancy.js +5 -1
- package/src/tenancy.js.map +1 -1
- package/src/lib/server/campaign-attribution-store.d.ts +0 -55
- package/src/lib/server/campaign-attribution-store.js +0 -95
- package/src/lib/server/campaign-attribution-store.js.map +0 -1
- package/src/lib/server/campaign-conversion-attribution.d.ts +0 -283
- package/src/lib/server/campaign-conversion-attribution.js +0 -450
- package/src/lib/server/campaign-conversion-attribution.js.map +0 -1
- package/src/lib/server/dataset-live-pages.d.ts +0 -179
- package/src/lib/server/dataset-live-pages.js +0 -421
- package/src/lib/server/dataset-live-pages.js.map +0 -1
- package/src/lib/server/email-revenue-attribution.d.ts +0 -185
- package/src/lib/server/email-revenue-attribution.js +0 -321
- 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
|
|
356
|
-
export declare const
|
|
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
|
|
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
|
|
363
|
-
*
|
|
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
|
|
366
|
-
/** Which recipients a
|
|
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
|
|
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
|
-
/**
|
|
385
|
-
|
|
328
|
+
/** Sends past {@link EMAIL_SEND_ENGAGEMENT_MAX_SENDS}. */
|
|
329
|
+
sendsOmitted: number;
|
|
386
330
|
}
|
|
387
331
|
/**
|
|
388
|
-
* The recipients of one site's
|
|
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
|
|
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
|
|
350
|
+
* their mail reached nobody.
|
|
407
351
|
*/
|
|
408
|
-
export declare function
|
|
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
|
-
/**
|
|
412
|
-
|
|
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<
|
|
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 {
|
|
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
|
|
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
|
|
804
|
-
*
|
|
805
|
-
*
|
|
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
|
|
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
|
|
645
|
+
*=========================================*/ /** The most recipient rows one send-engagement read returns. */ export const EMAIL_SEND_ENGAGEMENT_PAGE_SIZE = 25;
|
|
818
646
|
/**
|
|
819
|
-
* How many
|
|
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
|
|
824
|
-
*
|
|
825
|
-
*/ export const
|
|
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
|
|
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
|
|
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
|
|
846
|
-
*/ export async function
|
|
673
|
+
* their mail reached nobody.
|
|
674
|
+
*/ export async function readSendEngagement(options) {
|
|
847
675
|
var _options_limit;
|
|
848
|
-
const { hostId,
|
|
849
|
-
const pageSize = Math.max(1, Math.min(
|
|
850
|
-
const ids =
|
|
851
|
-
const
|
|
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
|
-
|
|
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
|
|
863
|
-
.where('hostId', '==', hostId)
|
|
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
|
-
|
|
716
|
+
sendsOmitted
|
|
888
717
|
};
|
|
889
718
|
} catch (error) {
|
|
890
|
-
console.error('[email-delivery-log]
|
|
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
|
|
877
|
+
* And what was CONCLUDED from this person's clicks, on every site.
|
|
1059
878
|
*
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1062
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
* and
|
|
1065
|
-
*
|
|
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
|
-
*
|
|
1068
|
-
* derivation
|
|
1069
|
-
*
|
|
1070
|
-
*
|
|
1071
|
-
|
|
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,
|