@blamejs/blamejs-shop 0.0.57 → 0.0.59

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.
@@ -0,0 +1,855 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.mailingAudiences
4
+ * @title Mailing audiences — operator-defined newsletter segmentation
5
+ *
6
+ * @intro
7
+ * Operators broadcast release notes, abandoned-cart nudges, and
8
+ * review-request prompts to slices of the `newsletter_signups`
9
+ * table. This primitive owns the definition + resolution of those
10
+ * slices ("audiences"). Each audience is a named rule set
11
+ * (subscribed-at age, source membership, tag filters, country,
12
+ * language, customer status, double-opt-in confirmation) the
13
+ * operator authors once; the broadcast pipeline resolves the
14
+ * audience to a recipient list per campaign.
15
+ *
16
+ * Composition:
17
+ * var aud = bShop.mailingAudiences.create({
18
+ * query: q,
19
+ * newsletter: bShop.newsletter.create({ query: q }),
20
+ * emailSuppressions: bShop.emailSuppressions.create({ query: q }),
21
+ * });
22
+ * await aud.defineAudience({
23
+ * slug: "release-watchers-eu",
24
+ * title: "Release watchers — EU/UK",
25
+ * rules: {
26
+ * subscribed_after: Date.now() - 365 * 86400000,
27
+ * double_opt_in: true,
28
+ * country_in: ["DE", "FR", "GB", "NL"],
29
+ * tag_any: ["release-notes", "security"],
30
+ * },
31
+ * });
32
+ * var page = await aud.resolve({ slug: "release-watchers-eu", limit: 100 });
33
+ * // page.emails_hashed — always populated; hashes only.
34
+ * // page.emails_normalised — empty unless include_plaintext.
35
+ * // page.next_cursor — HMAC-tagged page cursor.
36
+ *
37
+ * Suppression composition:
38
+ * When `opts.emailSuppressions` is supplied and `resolve` is
39
+ * called with `skip_suppressed: true` (the default), every
40
+ * candidate's `email_hash` is checked against the suppression
41
+ * table under the `marketing` scope. Hits drop out of the page +
42
+ * bump the `suppressed_count` the caller reports back through
43
+ * `auditDelivery`. Without an injected suppressions handle the
44
+ * filter is a no-op and the page returns the raw membership.
45
+ *
46
+ * Plaintext disclosure:
47
+ * `resolve` returns `emails_hashed` always — the primary key the
48
+ * downstream send pipeline uses to dedupe + correlate with the
49
+ * suppressions / consent / per-recipient tracking tables.
50
+ * `emails_normalised` is the plaintext recipient list and is
51
+ * gated behind `include_plaintext: true` on the resolve call.
52
+ * The operator wires that flag through their own admin auth
53
+ * gate (verifier role check, MFA-stepped-up session, etc.); this
54
+ * primitive surfaces the toggle but doesn't enforce the policy
55
+ * — the verifier check lives at the route layer that calls in.
56
+ *
57
+ * Cache freshness:
58
+ * `recompute()` walks every non-archived audience, re-evaluates
59
+ * its rules against the current `newsletter_signups` snapshot,
60
+ * and replaces the per-audience rows in
61
+ * `mailing_audience_membership_cache`. The scheduler runs this on
62
+ * a cron cadence the operator picks; `resolve` reads off the
63
+ * cache so a 100k-signup list paginates in O(page) rather than
64
+ * O(signups). The trade-off is operator-visible staleness — a
65
+ * signup that lands between recomputes won't appear in the next
66
+ * campaign send. Operators that need stricter freshness call
67
+ * `recompute()` synchronously before each send.
68
+ *
69
+ * Audit ledger:
70
+ * `auditDelivery({ slug, campaign_id, sent_count, suppressed_count })`
71
+ * writes a row per send. The compliance export reads
72
+ * `mailing_audience_deliveries` to demonstrate per-audience send
73
+ * volume + suppression honoring over a reporting window.
74
+ *
75
+ * @primitive mailingAudiences
76
+ * @related shop.newsletter, shop.emailSuppressions, b.pagination
77
+ */
78
+
79
+ // ---- constants ----------------------------------------------------------
80
+
81
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
82
+ var TAG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
83
+ var COUNTRY_RE = /^[A-Z]{2}$/;
84
+ var LANGUAGE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
85
+ var CUSTOMER_STATUS_RE = /^[a-z][a-z0-9_-]{0,31}$/;
86
+ var SOURCE_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
87
+ var MAX_TITLE_LEN = 200;
88
+ var MAX_RULE_LIST_LEN = 64;
89
+ var MAX_LIST_LIMIT = 500;
90
+ var DEFAULT_LIST_LIMIT = 100;
91
+ var LIST_ORDER_KEY = ["signup_id:asc"];
92
+ var KNOWN_RULE_KEYS = [
93
+ "subscribed_after",
94
+ "subscribed_before",
95
+ "source_in",
96
+ "tag_any",
97
+ "tag_all",
98
+ "country_in",
99
+ "double_opt_in",
100
+ "language_in",
101
+ "customer_status_in",
102
+ ];
103
+
104
+ var bShop;
105
+ function _b() {
106
+ if (!bShop) bShop = require("./index");
107
+ return bShop.framework;
108
+ }
109
+
110
+ // ---- validators ---------------------------------------------------------
111
+
112
+ function _validateSlug(s) {
113
+ if (typeof s !== "string" || !s.length) {
114
+ throw new TypeError("mailingAudiences: slug must be a non-empty string");
115
+ }
116
+ if (!SLUG_RE.test(s)) {
117
+ throw new TypeError(
118
+ "mailingAudiences: slug must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
119
+ );
120
+ }
121
+ return s;
122
+ }
123
+
124
+ function _validateTitle(s) {
125
+ if (typeof s !== "string" || !s.length) {
126
+ throw new TypeError("mailingAudiences: title must be a non-empty string");
127
+ }
128
+ if (s.length > MAX_TITLE_LEN) {
129
+ throw new TypeError(
130
+ "mailingAudiences: title must be <= " + MAX_TITLE_LEN + " chars"
131
+ );
132
+ }
133
+ if (/[\r\n\0]/.test(s)) {
134
+ throw new TypeError("mailingAudiences: title must not contain CR / LF / NUL");
135
+ }
136
+ return s;
137
+ }
138
+
139
+ function _validateTs(ts, label) {
140
+ if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
141
+ throw new TypeError(
142
+ "mailingAudiences: " + label + " must be a non-negative integer epoch-ms"
143
+ );
144
+ }
145
+ return ts;
146
+ }
147
+
148
+ function _validateStringList(arr, label, pattern) {
149
+ if (!Array.isArray(arr)) {
150
+ throw new TypeError(
151
+ "mailingAudiences: " + label + " must be an array of strings"
152
+ );
153
+ }
154
+ if (arr.length === 0) {
155
+ throw new TypeError(
156
+ "mailingAudiences: " + label + " must be a non-empty array"
157
+ );
158
+ }
159
+ if (arr.length > MAX_RULE_LIST_LEN) {
160
+ throw new TypeError(
161
+ "mailingAudiences: " + label + " must have <= " +
162
+ MAX_RULE_LIST_LEN + " entries"
163
+ );
164
+ }
165
+ var seen = Object.create(null);
166
+ var out = [];
167
+ for (var i = 0; i < arr.length; i += 1) {
168
+ var v = arr[i];
169
+ if (typeof v !== "string" || !v.length) {
170
+ throw new TypeError(
171
+ "mailingAudiences: " + label + "[" + i + "] must be a non-empty string"
172
+ );
173
+ }
174
+ if (!pattern.test(v)) {
175
+ throw new TypeError(
176
+ "mailingAudiences: " + label + "[" + i + "] does not match " + pattern.source
177
+ );
178
+ }
179
+ if (seen[v]) continue;
180
+ seen[v] = true;
181
+ out.push(v);
182
+ }
183
+ return out;
184
+ }
185
+
186
+ // Each known rule key is validated against its shape. Unknown keys
187
+ // are refused outright — operators should never silently lose a
188
+ // predicate to a typo, and a forward-compat rule key lands with an
189
+ // explicit primitive update (not a silent acceptance).
190
+ function _validateRules(rules) {
191
+ if (rules == null || typeof rules !== "object" || Array.isArray(rules)) {
192
+ throw new TypeError("mailingAudiences: rules must be an object");
193
+ }
194
+ var keys = Object.keys(rules);
195
+ for (var i = 0; i < keys.length; i += 1) {
196
+ if (KNOWN_RULE_KEYS.indexOf(keys[i]) === -1) {
197
+ throw new TypeError(
198
+ "mailingAudiences: unknown rule key '" + keys[i] + "' — allowed: " +
199
+ KNOWN_RULE_KEYS.join(", ")
200
+ );
201
+ }
202
+ }
203
+ var out = {};
204
+ if (rules.subscribed_after != null) {
205
+ out.subscribed_after = _validateTs(rules.subscribed_after, "rules.subscribed_after");
206
+ }
207
+ if (rules.subscribed_before != null) {
208
+ out.subscribed_before = _validateTs(rules.subscribed_before, "rules.subscribed_before");
209
+ }
210
+ if (out.subscribed_after != null && out.subscribed_before != null &&
211
+ out.subscribed_after > out.subscribed_before) {
212
+ throw new TypeError(
213
+ "mailingAudiences: rules.subscribed_after must be <= subscribed_before"
214
+ );
215
+ }
216
+ if (rules.source_in != null) {
217
+ out.source_in = _validateStringList(rules.source_in, "rules.source_in", SOURCE_RE);
218
+ }
219
+ if (rules.tag_any != null) {
220
+ out.tag_any = _validateStringList(rules.tag_any, "rules.tag_any", TAG_RE);
221
+ }
222
+ if (rules.tag_all != null) {
223
+ out.tag_all = _validateStringList(rules.tag_all, "rules.tag_all", TAG_RE);
224
+ }
225
+ if (rules.country_in != null) {
226
+ out.country_in = _validateStringList(rules.country_in, "rules.country_in", COUNTRY_RE);
227
+ }
228
+ if (rules.double_opt_in != null) {
229
+ if (typeof rules.double_opt_in !== "boolean") {
230
+ throw new TypeError(
231
+ "mailingAudiences: rules.double_opt_in must be a boolean"
232
+ );
233
+ }
234
+ out.double_opt_in = rules.double_opt_in;
235
+ }
236
+ if (rules.language_in != null) {
237
+ out.language_in = _validateStringList(rules.language_in, "rules.language_in", LANGUAGE_RE);
238
+ }
239
+ if (rules.customer_status_in != null) {
240
+ out.customer_status_in = _validateStringList(
241
+ rules.customer_status_in, "rules.customer_status_in", CUSTOMER_STATUS_RE
242
+ );
243
+ }
244
+ return out;
245
+ }
246
+
247
+ function _validateLimit(n) {
248
+ if (n == null) return DEFAULT_LIST_LIMIT;
249
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
250
+ throw new TypeError(
251
+ "mailingAudiences: limit must be 1..." + MAX_LIST_LIMIT
252
+ );
253
+ }
254
+ return n;
255
+ }
256
+
257
+ function _validateCampaignId(s) {
258
+ if (typeof s !== "string" || !s.length) {
259
+ throw new TypeError(
260
+ "mailingAudiences: campaign_id must be a non-empty string"
261
+ );
262
+ }
263
+ if (s.length > 128) {
264
+ throw new TypeError("mailingAudiences: campaign_id must be <= 128 chars");
265
+ }
266
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/.test(s)) {
267
+ throw new TypeError(
268
+ "mailingAudiences: campaign_id must match /[A-Za-z0-9][A-Za-z0-9._:-]*[A-Za-z0-9]/"
269
+ );
270
+ }
271
+ return s;
272
+ }
273
+
274
+ function _validateNonNegInt(n, label) {
275
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
276
+ throw new TypeError(
277
+ "mailingAudiences: " + label + " must be a non-negative integer"
278
+ );
279
+ }
280
+ return n;
281
+ }
282
+
283
+ // ---- in-memory rule evaluator ------------------------------------------
284
+ //
285
+ // Used by `recompute()` and `resolve()` (cold path — when no cache row
286
+ // exists yet). The hot path reads `mailing_audience_membership_cache`
287
+ // directly; this evaluator only runs over the full signup row set
288
+ // during a recompute pass. The cost-bound stays operator-visible
289
+ // because the scheduler controls when recompute happens.
290
+
291
+ function _csvHasAny(csv, needles) {
292
+ if (!csv) return false;
293
+ var have = csv.split(",");
294
+ for (var i = 0; i < have.length; i += 1) {
295
+ var t = have[i];
296
+ if (!t) continue;
297
+ if (needles.indexOf(t) !== -1) return true;
298
+ }
299
+ return false;
300
+ }
301
+
302
+ function _csvHasAll(csv, needles) {
303
+ if (!csv) return false;
304
+ var have = csv.split(",");
305
+ for (var i = 0; i < needles.length; i += 1) {
306
+ if (have.indexOf(needles[i]) === -1) return false;
307
+ }
308
+ return true;
309
+ }
310
+
311
+ function _matches(row, rules) {
312
+ if (rules.subscribed_after != null &&
313
+ !(Number(row.created_at) >= rules.subscribed_after)) {
314
+ return false;
315
+ }
316
+ if (rules.subscribed_before != null &&
317
+ !(Number(row.created_at) <= rules.subscribed_before)) {
318
+ return false;
319
+ }
320
+ if (rules.source_in != null &&
321
+ rules.source_in.indexOf(row.source) === -1) {
322
+ return false;
323
+ }
324
+ if (rules.tag_any != null &&
325
+ !_csvHasAny(row.tags_csv || "", rules.tag_any)) {
326
+ return false;
327
+ }
328
+ if (rules.tag_all != null &&
329
+ !_csvHasAll(row.tags_csv || "", rules.tag_all)) {
330
+ return false;
331
+ }
332
+ if (rules.country_in != null &&
333
+ (!row.country || rules.country_in.indexOf(row.country) === -1)) {
334
+ return false;
335
+ }
336
+ if (rules.double_opt_in === true && row.double_opt_in_at == null) {
337
+ return false;
338
+ }
339
+ if (rules.double_opt_in === false && row.double_opt_in_at != null) {
340
+ return false;
341
+ }
342
+ if (rules.language_in != null &&
343
+ (!row.language || rules.language_in.indexOf(row.language) === -1)) {
344
+ return false;
345
+ }
346
+ if (rules.customer_status_in != null &&
347
+ (!row.customer_status ||
348
+ rules.customer_status_in.indexOf(row.customer_status) === -1)) {
349
+ return false;
350
+ }
351
+ // Unsubscribed rows are never audience members regardless of rule
352
+ // shape — opt-out is the universal short-circuit. The operator can
353
+ // re-include via `newsletter.resubscribe`.
354
+ if (row.unsubscribed_at != null) return false;
355
+ return true;
356
+ }
357
+
358
+ // ---- factory ------------------------------------------------------------
359
+
360
+ function create(opts) {
361
+ opts = opts || {};
362
+ var query = opts.query;
363
+ if (!query) {
364
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
365
+ }
366
+ // The `newsletter` handle is optional — the primitive composes
367
+ // `newsletter_signups` directly via `query`. Operators pass the
368
+ // handle when they want to short-circuit a resolve into a fresh
369
+ // recompute (e.g. count() against a freshly-mutated table).
370
+ var newsletterHandle = opts.newsletter || null;
371
+ var suppressionsHandle = opts.emailSuppressions || null;
372
+
373
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
374
+ if (process.env.NODE_ENV === "production") {
375
+ throw new Error(
376
+ "mailingAudiences.create: opts.cursorSecret is required in production"
377
+ );
378
+ }
379
+ opts.cursorSecret = "mailing-audiences-cursor-dev-only";
380
+ }
381
+ var cursorSecret = opts.cursorSecret;
382
+
383
+ // Cold-path resolver — walks every signup row, applies the rule
384
+ // evaluator, replaces the cache rows for this slug. Returns the
385
+ // member id list so `recompute()` can roll up counts.
386
+ async function _recomputeOne(slug, rules, now) {
387
+ var r = await query(
388
+ "SELECT id, email_hash, email_normalized, source, created_at, " +
389
+ "unsubscribed_at, tags_csv, country, language, customer_status, " +
390
+ "double_opt_in_at " +
391
+ "FROM newsletter_signups",
392
+ [],
393
+ );
394
+ var members = [];
395
+ for (var i = 0; i < r.rows.length; i += 1) {
396
+ if (_matches(r.rows[i], rules)) members.push(r.rows[i].id);
397
+ }
398
+ // Replace the slug's cache rows in two steps — SQLite has no
399
+ // multi-row UPSERT that's portable across the D1 SQLite dialect
400
+ // we run on, so the safer route is DELETE + INSERT. Bounded by
401
+ // the audience size (operator picks); a 100k-member audience is
402
+ // still ~10ms on D1.
403
+ await query(
404
+ "DELETE FROM mailing_audience_membership_cache WHERE slug = ?1",
405
+ [slug],
406
+ );
407
+ for (var j = 0; j < members.length; j += 1) {
408
+ await query(
409
+ "INSERT INTO mailing_audience_membership_cache " +
410
+ "(slug, signup_id, refreshed_at) VALUES (?1, ?2, ?3)",
411
+ [slug, members[j], now],
412
+ );
413
+ }
414
+ return members;
415
+ }
416
+
417
+ function _decodeCursor(cursor) {
418
+ if (cursor == null) return null;
419
+ if (typeof cursor !== "string") {
420
+ throw new TypeError(
421
+ "mailingAudiences: cursor must be an opaque string or null"
422
+ );
423
+ }
424
+ try {
425
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
426
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
427
+ throw new TypeError(
428
+ "mailingAudiences: cursor orderKey mismatch"
429
+ );
430
+ }
431
+ return state.vals;
432
+ } catch (e) {
433
+ if (e instanceof TypeError) throw e;
434
+ throw new TypeError(
435
+ "mailingAudiences: cursor — " + (e && e.message || "malformed")
436
+ );
437
+ }
438
+ }
439
+
440
+ function _encodeCursor(lastSignupId) {
441
+ return _b().pagination.encodeCursor({
442
+ orderKey: LIST_ORDER_KEY,
443
+ vals: [lastSignupId],
444
+ forward: true,
445
+ }, cursorSecret);
446
+ }
447
+
448
+ async function _getAudienceRow(slug) {
449
+ var r = await query(
450
+ "SELECT slug, title, rules_json, archived_at, created_at, updated_at " +
451
+ "FROM mailing_audiences WHERE slug = ?1 LIMIT 1",
452
+ [slug],
453
+ );
454
+ return r.rows[0] || null;
455
+ }
456
+
457
+ return {
458
+ KNOWN_RULE_KEYS: KNOWN_RULE_KEYS,
459
+
460
+ // Define (or upsert) an audience. The rules object is normalised
461
+ // before persisting — unknown keys refused, lists deduped, types
462
+ // checked. Re-defining the same slug bumps `updated_at` + replaces
463
+ // rules; archived audiences un-archive on re-define so an
464
+ // operator can revive an audience without inventing a new slug.
465
+ defineAudience: async function (input) {
466
+ if (!input || typeof input !== "object") {
467
+ throw new TypeError("mailingAudiences.defineAudience: input object required");
468
+ }
469
+ var slug = _validateSlug(input.slug);
470
+ var title = _validateTitle(input.title);
471
+ var rules = _validateRules(input.rules);
472
+ var now = Date.now();
473
+
474
+ var existing = await _getAudienceRow(slug);
475
+ var rulesJson;
476
+ try {
477
+ rulesJson = JSON.stringify(rules);
478
+ } catch (_e) {
479
+ // Validators above only emit JSON-safe primitives; this is
480
+ // defensive — a circular ref would have to come from an
481
+ // operator-built proxy, which the validators wouldn't touch.
482
+ throw new TypeError("mailingAudiences.defineAudience: rules not serialisable");
483
+ }
484
+ if (existing) {
485
+ await query(
486
+ "UPDATE mailing_audiences SET title = ?1, rules_json = ?2, " +
487
+ "archived_at = NULL, updated_at = ?3 WHERE slug = ?4",
488
+ [title, rulesJson, now, slug],
489
+ );
490
+ return {
491
+ slug: slug,
492
+ title: title,
493
+ rules: rules,
494
+ archived_at: null,
495
+ created_at: Number(existing.created_at),
496
+ updated_at: now,
497
+ status: "updated",
498
+ };
499
+ }
500
+ await query(
501
+ "INSERT INTO mailing_audiences " +
502
+ "(slug, title, rules_json, archived_at, created_at, updated_at) " +
503
+ "VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
504
+ [slug, title, rulesJson, now],
505
+ );
506
+ return {
507
+ slug: slug,
508
+ title: title,
509
+ rules: rules,
510
+ archived_at: null,
511
+ created_at: now,
512
+ updated_at: now,
513
+ status: "new",
514
+ };
515
+ },
516
+
517
+ // Resolve an audience to a page of recipients. The cache rows
518
+ // are joined back to `newsletter_signups` for the hash +
519
+ // normalised columns; suppressions (if injected) drop hits per
520
+ // hash. `emails_normalised` is empty unless `include_plaintext:
521
+ // true`. Pagination is cursor-based and HMAC-tagged so an
522
+ // operator can't hand-craft one to skip past hidden rows.
523
+ resolve: async function (resolveOpts) {
524
+ resolveOpts = resolveOpts || {};
525
+ var slug = _validateSlug(resolveOpts.slug);
526
+ var limit = _validateLimit(resolveOpts.limit);
527
+ var cursorVals = _decodeCursor(resolveOpts.cursor);
528
+ var skipSuppressed = resolveOpts.skip_suppressed !== false;
529
+ var includePlaintext = resolveOpts.include_plaintext === true;
530
+
531
+ var audience = await _getAudienceRow(slug);
532
+ if (!audience) {
533
+ throw new TypeError(
534
+ "mailingAudiences.resolve: audience '" + slug + "' not found"
535
+ );
536
+ }
537
+ if (audience.archived_at != null) {
538
+ throw new TypeError(
539
+ "mailingAudiences.resolve: audience '" + slug + "' is archived"
540
+ );
541
+ }
542
+ var sql = "SELECT s.id AS signup_id, s.email_hash, s.email_normalized " +
543
+ "FROM mailing_audience_membership_cache c " +
544
+ "JOIN newsletter_signups s ON s.id = c.signup_id " +
545
+ "WHERE c.slug = ?1";
546
+ var params = [slug];
547
+ var p = 2;
548
+ if (cursorVals) {
549
+ sql += " AND c.signup_id > ?" + p;
550
+ params.push(cursorVals[0]);
551
+ p += 1;
552
+ }
553
+ sql += " ORDER BY c.signup_id ASC LIMIT ?" + p;
554
+ // Over-fetch by the configured limit so the suppression filter
555
+ // doesn't underfill the page on a high-suppression slug.
556
+ // Bounded by `MAX_LIST_LIMIT * 2` so a pathological audience
557
+ // can't blow memory; if the over-fetch saturates and the page
558
+ // still underfills, the caller paginates to the next cursor.
559
+ var overfetch = Math.min(MAX_LIST_LIMIT * 2, limit * 2);
560
+ params.push(overfetch);
561
+
562
+ var rows = (await query(sql, params)).rows;
563
+ var hashes = [];
564
+ var emails = [];
565
+ var lastId = null;
566
+ var droppedSuppressed = 0;
567
+
568
+ for (var i = 0; i < rows.length && hashes.length < limit; i += 1) {
569
+ var row = rows[i];
570
+ lastId = row.signup_id;
571
+ if (skipSuppressed && suppressionsHandle) {
572
+ var ssView = await suppressionsHandle.isSuppressed({
573
+ email: row.email_normalized,
574
+ scope: "marketing",
575
+ });
576
+ if (ssView.suppressed) {
577
+ droppedSuppressed += 1;
578
+ continue;
579
+ }
580
+ }
581
+ hashes.push(row.email_hash);
582
+ if (includePlaintext) emails.push(row.email_normalized);
583
+ }
584
+
585
+ var next = null;
586
+ // If the over-fetch returned at least one more row past the
587
+ // last we emitted, a next page exists. The cursor anchors on
588
+ // the last cache row we examined — including dropped
589
+ // suppressions — so the next page resumes past them.
590
+ if (rows.length > 0 && hashes.length === limit) {
591
+ next = _encodeCursor(lastId);
592
+ }
593
+ return {
594
+ slug: slug,
595
+ emails_hashed: hashes,
596
+ emails_normalised: emails,
597
+ suppressed_count: droppedSuppressed,
598
+ next_cursor: next,
599
+ };
600
+ },
601
+
602
+ // Count of cached members for an audience. Reads off
603
+ // `mailing_audience_membership_cache` — does NOT re-resolve.
604
+ // Operators that need cache-fresh counts call `recompute()`
605
+ // first.
606
+ count: async function (slug) {
607
+ _validateSlug(slug);
608
+ var audience = await _getAudienceRow(slug);
609
+ if (!audience) {
610
+ throw new TypeError(
611
+ "mailingAudiences.count: audience '" + slug + "' not found"
612
+ );
613
+ }
614
+ var r = await query(
615
+ "SELECT COUNT(*) AS n FROM mailing_audience_membership_cache WHERE slug = ?1",
616
+ [slug],
617
+ );
618
+ return Number((r.rows[0] || {}).n || 0);
619
+ },
620
+
621
+ // Single-audience operator dashboard view. Returns the row + the
622
+ // parsed rules object (not the JSON string) for ergonomic
623
+ // rendering. Returns null on miss rather than throwing — the
624
+ // dashboard renders a "not found" view; throwing would force the
625
+ // caller to wrap every read in try/catch.
626
+ getAudience: async function (slug) {
627
+ _validateSlug(slug);
628
+ var row = await _getAudienceRow(slug);
629
+ if (!row) return null;
630
+ var rules;
631
+ try { rules = JSON.parse(row.rules_json); }
632
+ catch (_e) { rules = {}; }
633
+ return {
634
+ slug: row.slug,
635
+ title: row.title,
636
+ rules: rules,
637
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
638
+ created_at: Number(row.created_at),
639
+ updated_at: Number(row.updated_at),
640
+ };
641
+ },
642
+
643
+ // Operator dashboard list. Active-only by default; pass
644
+ // `include_archived: true` to surface the soft-deleted entries
645
+ // for compliance / undo-style flows.
646
+ listAudiences: async function (listOpts) {
647
+ listOpts = listOpts || {};
648
+ var includeArchived = listOpts.include_archived === true;
649
+ var sql = "SELECT slug, title, rules_json, archived_at, created_at, updated_at " +
650
+ "FROM mailing_audiences";
651
+ if (!includeArchived) sql += " WHERE archived_at IS NULL";
652
+ sql += " ORDER BY slug ASC";
653
+ var rows = (await query(sql, [])).rows;
654
+ var out = [];
655
+ for (var i = 0; i < rows.length; i += 1) {
656
+ var rules;
657
+ try { rules = JSON.parse(rows[i].rules_json); }
658
+ catch (_e) { rules = {}; }
659
+ out.push({
660
+ slug: rows[i].slug,
661
+ title: rows[i].title,
662
+ rules: rules,
663
+ archived_at: rows[i].archived_at == null ? null : Number(rows[i].archived_at),
664
+ created_at: Number(rows[i].created_at),
665
+ updated_at: Number(rows[i].updated_at),
666
+ });
667
+ }
668
+ return out;
669
+ },
670
+
671
+ // Update an audience's title and / or rules. Either or both may
672
+ // appear in the patch; absent keys leave the persisted value
673
+ // alone. Archived audiences refuse updates — the operator
674
+ // un-archives via `defineAudience` (which is the upsert path).
675
+ update: async function (slug, patch) {
676
+ _validateSlug(slug);
677
+ if (!patch || typeof patch !== "object") {
678
+ throw new TypeError("mailingAudiences.update: patch object required");
679
+ }
680
+ var existing = await _getAudienceRow(slug);
681
+ if (!existing) {
682
+ throw new TypeError(
683
+ "mailingAudiences.update: audience '" + slug + "' not found"
684
+ );
685
+ }
686
+ if (existing.archived_at != null) {
687
+ throw new TypeError(
688
+ "mailingAudiences.update: audience '" + slug + "' is archived — " +
689
+ "use defineAudience to revive"
690
+ );
691
+ }
692
+ var title;
693
+ if (patch.title != null) title = _validateTitle(patch.title);
694
+ else title = existing.title;
695
+ var rules;
696
+ if (patch.rules != null) rules = _validateRules(patch.rules);
697
+ else rules = JSON.parse(existing.rules_json);
698
+ var now = Date.now();
699
+ await query(
700
+ "UPDATE mailing_audiences SET title = ?1, rules_json = ?2, updated_at = ?3 " +
701
+ "WHERE slug = ?4",
702
+ [title, JSON.stringify(rules), now, slug],
703
+ );
704
+ return {
705
+ slug: slug,
706
+ title: title,
707
+ rules: rules,
708
+ archived_at: null,
709
+ created_at: Number(existing.created_at),
710
+ updated_at: now,
711
+ status: "updated",
712
+ };
713
+ },
714
+
715
+ // Soft-delete — stamps `archived_at`. The membership cache is
716
+ // dropped at the same time so a stale page can't resolve against
717
+ // a dormant audience; the audit ledger (deliveries) stays intact
718
+ // so the compliance export keeps reading the historical sends.
719
+ archive: async function (slug) {
720
+ _validateSlug(slug);
721
+ var existing = await _getAudienceRow(slug);
722
+ if (!existing) {
723
+ throw new TypeError(
724
+ "mailingAudiences.archive: audience '" + slug + "' not found"
725
+ );
726
+ }
727
+ if (existing.archived_at != null) {
728
+ return {
729
+ slug: slug,
730
+ archived_at: Number(existing.archived_at),
731
+ status: "already-archived",
732
+ };
733
+ }
734
+ var now = Date.now();
735
+ await query(
736
+ "UPDATE mailing_audiences SET archived_at = ?1, updated_at = ?1 " +
737
+ "WHERE slug = ?2",
738
+ [now, slug],
739
+ );
740
+ await query(
741
+ "DELETE FROM mailing_audience_membership_cache WHERE slug = ?1",
742
+ [slug],
743
+ );
744
+ return { slug: slug, archived_at: now, status: "archived" };
745
+ },
746
+
747
+ // Scheduler entry point. Walks every non-archived audience and
748
+ // replaces its membership cache rows. Returns the per-slug
749
+ // member counts so the scheduler can log a recompute summary.
750
+ recompute: async function () {
751
+ var rows = (await query(
752
+ "SELECT slug, rules_json FROM mailing_audiences WHERE archived_at IS NULL",
753
+ [],
754
+ )).rows;
755
+ var now = Date.now();
756
+ var counts = {};
757
+ for (var i = 0; i < rows.length; i += 1) {
758
+ var rules;
759
+ try { rules = JSON.parse(rows[i].rules_json); }
760
+ catch (_e) {
761
+ // Persisted JSON failed to parse — operator-visible drift.
762
+ // Skip the slug rather than crash the whole sweep so one
763
+ // bad row doesn't stall the scheduler; the count surfaces
764
+ // as 0 in the return so the operator notices.
765
+ counts[rows[i].slug] = 0;
766
+ continue;
767
+ }
768
+ var members = await _recomputeOne(rows[i].slug, rules, now);
769
+ counts[rows[i].slug] = members.length;
770
+ }
771
+ return { refreshed_at: now, counts: counts };
772
+ },
773
+
774
+ // Append-only delivery audit. One row per send the broadcast
775
+ // pipeline performs. The compliance export reads
776
+ // `mailing_audience_deliveries` to demonstrate per-audience
777
+ // send volume + suppression honoring.
778
+ auditDelivery: async function (input) {
779
+ if (!input || typeof input !== "object") {
780
+ throw new TypeError(
781
+ "mailingAudiences.auditDelivery: input object required"
782
+ );
783
+ }
784
+ var slug = _validateSlug(input.slug);
785
+ var campaignId = _validateCampaignId(input.campaign_id);
786
+ var sentCount = _validateNonNegInt(input.sent_count, "sent_count");
787
+ var suppressedCount = _validateNonNegInt(input.suppressed_count, "suppressed_count");
788
+ var occurredAt;
789
+ if (input.occurred_at == null) {
790
+ occurredAt = Date.now();
791
+ } else {
792
+ occurredAt = _validateTs(input.occurred_at, "occurred_at");
793
+ }
794
+ // Audience existence is a soft check — the audit row records
795
+ // what the operator sent under the slug they used. An archived
796
+ // or deleted audience still gets logged so the compliance
797
+ // export sees the historical activity. The slug-existence
798
+ // refusal is reserved for resolve / count where stale data
799
+ // would mislead the caller.
800
+ var id = _b().uuid.v7();
801
+ await query(
802
+ "INSERT INTO mailing_audience_deliveries " +
803
+ "(id, slug, campaign_id, sent_count, suppressed_count, occurred_at) " +
804
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
805
+ [id, slug, campaignId, sentCount, suppressedCount, occurredAt],
806
+ );
807
+ return {
808
+ id: id,
809
+ slug: slug,
810
+ campaign_id: campaignId,
811
+ sent_count: sentCount,
812
+ suppressed_count: suppressedCount,
813
+ occurred_at: occurredAt,
814
+ };
815
+ },
816
+
817
+ // Operator compliance read — paginated delivery audit log,
818
+ // optionally narrowed to a slug. Sorted (occurred_at DESC,
819
+ // id DESC) so the most-recent send surfaces first. Uses the
820
+ // primitive's cursorSecret so the same HMAC tag protects cross-
821
+ // surface pagination.
822
+ listDeliveries: async function (listOpts) {
823
+ listOpts = listOpts || {};
824
+ var limit = _validateLimit(listOpts.limit);
825
+ var slug = null;
826
+ if (listOpts.slug != null) slug = _validateSlug(listOpts.slug);
827
+ var where = [];
828
+ var params = [];
829
+ var p = 1;
830
+ if (slug) {
831
+ where.push("slug = ?" + p);
832
+ params.push(slug);
833
+ p += 1;
834
+ }
835
+ var sql = "SELECT id, slug, campaign_id, sent_count, suppressed_count, occurred_at " +
836
+ "FROM mailing_audience_deliveries" +
837
+ (where.length ? " WHERE " + where.join(" AND ") : "") +
838
+ " ORDER BY occurred_at DESC, id DESC LIMIT ?" + p;
839
+ params.push(limit);
840
+ var rows = (await query(sql, params)).rows;
841
+ return { rows: rows };
842
+ },
843
+
844
+ // Optional accessor — surfaces whether the primitive was wired
845
+ // with a newsletter handle. Tests + operator-debugging surfaces
846
+ // use this to assert composition; the resolve / recompute paths
847
+ // don't need it (they go through `query` directly).
848
+ _newsletterHandle: function () { return newsletterHandle; },
849
+ };
850
+ }
851
+
852
+ module.exports = {
853
+ create: create,
854
+ KNOWN_RULE_KEYS: KNOWN_RULE_KEYS,
855
+ };