@blamejs/blamejs-shop 0.0.70 → 0.0.75

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 (46) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +42 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/loyalty-earn-rules.js +786 -0
  22. package/lib/marketing-budget.js +792 -0
  23. package/lib/operator-activity-feed.js +977 -0
  24. package/lib/operator-approvals.js +942 -0
  25. package/lib/operator-help-center.js +1020 -0
  26. package/lib/operator-inbox.js +889 -0
  27. package/lib/operator-sessions.js +701 -0
  28. package/lib/order-exchanges.js +602 -0
  29. package/lib/product-compare.js +804 -0
  30. package/lib/pwa-manifest.js +1005 -0
  31. package/lib/referral-leaderboard.js +612 -0
  32. package/lib/sales-tax-filings.js +807 -0
  33. package/lib/search-ranking.js +859 -0
  34. package/lib/shipping-insurance.js +757 -0
  35. package/lib/shrinkage-report.js +1182 -0
  36. package/lib/sidebar-widgets.js +952 -0
  37. package/lib/smart-restocking.js +1048 -0
  38. package/lib/split-shipments.js +7 -1
  39. package/lib/stock-receipts.js +834 -0
  40. package/lib/subscription-analytics.js +1032 -0
  41. package/lib/suggestion-box.js +921 -0
  42. package/lib/tax-remittance.js +625 -0
  43. package/lib/vendor-invoices.js +1021 -0
  44. package/lib/winback-campaigns.js +1350 -0
  45. package/lib/wishlist-digest.js +1133 -0
  46. package/package.json +1 -1
@@ -0,0 +1,1133 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.wishlistDigest
4
+ * @title Weekly / monthly wishlist digest emails — rollup summaries
5
+ * with stock changes + price drops since the last send.
6
+ *
7
+ * @intro
8
+ * Distinct from `wishlistAlerts` (event-driven "this SKU just dropped
9
+ * 20%"); the digest is a periodic rollup the operator authors as a
10
+ * weekly or monthly cadence + customers opt into per-cadence. The
11
+ * shape:
12
+ *
13
+ * var wd = b.shop.wishlistDigest.create({
14
+ * query: q,
15
+ * wishlist: wishlistPrimitive,
16
+ * catalog: catalogPrimitive,
17
+ * email: emailPrimitive,
18
+ * emailSuppressions: suppPrimitive, // optional
19
+ * });
20
+ *
21
+ * // Operator defines the cadence once.
22
+ * await wd.defineSchedule({
23
+ * slug: "weekly-monday-9am",
24
+ * frequency: "weekly",
25
+ * day_of_week: 1, // Mon = 1 (0..6, Sun = 0)
26
+ * time_local: "09:00",
27
+ * timezone: "Europe/London",
28
+ * });
29
+ *
30
+ * // Customer-portal opt-in. `next_dispatch_at` is computed from
31
+ * // the schedule's frequency / day-of-week|month / time_local /
32
+ * // timezone — pure JS via `Intl.DateTimeFormat`, no offset math.
33
+ * await wd.enrollCustomer({
34
+ * customer_id: customerId,
35
+ * schedule_slug: "weekly-monday-9am",
36
+ * });
37
+ *
38
+ * // Scheduler tick — operator wires this to a cron / Workers
39
+ * // Cron Trigger. Pulls every active enrollment whose
40
+ * // next_dispatch_at is due, composes + dispatches the digest,
41
+ * // stamps a `wishlist_digest_sent` row, advances next_dispatch_at
42
+ * // by one period.
43
+ * await wd.dispatchTick({ now: Date.now() });
44
+ *
45
+ * Verbs:
46
+ * defineSchedule — register / replace a cadence. Per-slug
47
+ * uniqueness; redefining the active row patches it in place. The
48
+ * frequency-specific shape rules are enforced at the SQL CHECK
49
+ * constraint AND the JS validator (defense-in-depth so a hand-
50
+ * UPDATE'd row that violates the CHECK fails at insert, and a
51
+ * bad caller fails at the verb boundary with a typed error).
52
+ *
53
+ * enrollCustomer — opt a customer in. Refuses if the schedule is
54
+ * archived. Re-enrolling the same (customer, schedule) pair is a
55
+ * no-op when active, a re-activate when paused, and refused when
56
+ * cancelled (operators reauthor by calling resumeEnrollment +
57
+ * the dispatcher picks up the fresh next_dispatch_at).
58
+ *
59
+ * dispatchTick — pull every active enrollment whose
60
+ * next_dispatch_at <= now, compose the digest via composeDigest,
61
+ * send via the injected `email.sendWishlistDigest` handle, stamp
62
+ * a sent ledger row, advance next_dispatch_at by one period. The
63
+ * suppressions dep (when wired) short-circuits sends to addresses
64
+ * on the suppress list — the row is still ledger'd with
65
+ * item_count = 0 so the cadence stays on rails, but no email
66
+ * fires (and the operator's metrics show the suppression rate).
67
+ *
68
+ * recordDigestSent — terminal marker for async mailer hooks. The
69
+ * synchronous path through dispatchTick stamps the ledger inline;
70
+ * this verb is for deferred mailer ACKs (the operator-supplied
71
+ * mailer returns immediately + records the actual send out-of-
72
+ * band — e.g. an AWS SES async callback or a Mailchimp transactional
73
+ * webhook).
74
+ *
75
+ * pauseEnrollment / resumeEnrollment — temporary opt-out. Pausing
76
+ * captures a `paused_reason` (operator audit trail, customer-
77
+ * facing "you paused because: X"); resuming recomputes
78
+ * next_dispatch_at from the current wall clock + the schedule.
79
+ *
80
+ * enrollmentsForCustomer — customer-portal read. Newest-first
81
+ * across all statuses; the customer's account page shows each
82
+ * subscription with its next-dispatch ETA.
83
+ *
84
+ * metricsForSchedule — operator dashboard rollup — count of
85
+ * digests sent + sum of item_counts in a window for one slug.
86
+ *
87
+ * composeDigest({ customer_id }) — returns the digest body
88
+ * (HTML + text) for the customer's wishlist as it stands NOW.
89
+ * Pure-read; no DB writes. Wired into dispatchTick + exposed
90
+ * directly so the operator can preview the next digest from the
91
+ * admin UI without firing the email.
92
+ *
93
+ * Composition:
94
+ * - b.uuid.v7 — enrollment + sent-ledger row ids
95
+ * - b.guardUuid — customer_id sanitization
96
+ * - wishlist — listForCustomer to drive composeDigest
97
+ * - catalog — products.get + prices.current for the digest
98
+ * lines; price changes since the last send drive
99
+ * the "price drops" section
100
+ * - email — sendWishlistDigest({ customer_email, lines, ... })
101
+ * - emailSuppressions (optional) — isSuppressed short-circuit
102
+ *
103
+ * Monotonic clock: a per-factory monotonic timestamp guarantees that
104
+ * two enrollments / sent-ledger rows written inside the same wall-
105
+ * clock millisecond carry strictly-increasing values; the dispatcher's
106
+ * batch read returns deterministic order without depending on
107
+ * secondary tiebreakers.
108
+ *
109
+ * @primitive wishlistDigest
110
+ * @related shop.wishlist, shop.catalog, shop.email, shop.wishlistAlerts
111
+ */
112
+
113
+ var bShop;
114
+ function _b() {
115
+ if (!bShop) bShop = require("./index");
116
+ return bShop.framework;
117
+ }
118
+
119
+ // ---- constants ----------------------------------------------------------
120
+
121
+ var FREQUENCIES = Object.freeze(["weekly", "monthly"]);
122
+ var STATUSES = Object.freeze(["active", "paused", "cancelled"]);
123
+
124
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
125
+ var TIME_LOCAL_RE = /^([01][0-9]|2[0-3]):[0-5][0-9]$/;
126
+ var MAX_PAUSE_REASON = 1024;
127
+ var MAX_BATCH_SIZE = 500;
128
+ var DEFAULT_BATCH_SIZE = 100;
129
+ var MAX_LIMIT = 500;
130
+ var DEFAULT_LIMIT = 50;
131
+ var MAX_DIGEST_LINES = 200;
132
+
133
+ var DAY_MS = 24 * 60 * 60 * 1000;
134
+
135
+ // ---- html escape (local; vendored blamejs's _htmlEscape is private to
136
+ // the mail primitive). Same rules — five named entities, no tag/attr
137
+ // context awareness because we only embed in text nodes / hrefs that
138
+ // we control directly.
139
+ function _htmlEscape(s) {
140
+ return String(s)
141
+ .replace(/&/g, "&amp;")
142
+ .replace(/</g, "&lt;")
143
+ .replace(/>/g, "&gt;")
144
+ .replace(/"/g, "&quot;")
145
+ .replace(/'/g, "&#39;");
146
+ }
147
+
148
+ // ---- validators ---------------------------------------------------------
149
+
150
+ function _slug(s, label) {
151
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
152
+ throw new TypeError("wishlistDigest: " + (label || "slug") +
153
+ " must match /^[a-z0-9][a-z0-9._-]*$/ (lowercase alnum + . _ -, 1..128 chars)");
154
+ }
155
+ return s;
156
+ }
157
+
158
+ function _frequency(s) {
159
+ if (typeof s !== "string" || FREQUENCIES.indexOf(s) === -1) {
160
+ throw new TypeError("wishlistDigest: frequency must be one of " + FREQUENCIES.join(", "));
161
+ }
162
+ return s;
163
+ }
164
+
165
+ function _dayOfWeek(n) {
166
+ if (!Number.isInteger(n) || n < 0 || n > 6) {
167
+ throw new TypeError("wishlistDigest: day_of_week must be an integer in [0, 6] (Sun = 0)");
168
+ }
169
+ return n;
170
+ }
171
+
172
+ function _dayOfMonth(n) {
173
+ if (!Number.isInteger(n) || n < 1 || n > 28) {
174
+ throw new TypeError("wishlistDigest: day_of_month must be an integer in [1, 28] " +
175
+ "(capped at 28 so February doesn't skip the send)");
176
+ }
177
+ return n;
178
+ }
179
+
180
+ function _timeLocal(s) {
181
+ if (typeof s !== "string" || !TIME_LOCAL_RE.test(s)) {
182
+ throw new TypeError("wishlistDigest: time_local must be HH:MM (24-hour clock, e.g. 09:00 or 18:30)");
183
+ }
184
+ return s;
185
+ }
186
+
187
+ function _timezone(s) {
188
+ if (typeof s !== "string" || !s.length) {
189
+ throw new TypeError("wishlistDigest: timezone must be a non-empty IANA timezone name (e.g. Europe/London)");
190
+ }
191
+ try {
192
+ // Probe via Intl — throws RangeError on unknown zones. The framework
193
+ // doesn't ship a vendored zone catalog; node ships the ICU bundle.
194
+ new Intl.DateTimeFormat("en-US", { timeZone: s });
195
+ } catch (_e) {
196
+ throw new TypeError("wishlistDigest: timezone is not a known IANA timezone (got " + JSON.stringify(s) + ")");
197
+ }
198
+ return s;
199
+ }
200
+
201
+ function _customerId(s) {
202
+ try {
203
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
204
+ } catch (e) {
205
+ throw new TypeError("wishlistDigest: customer_id — " + (e && e.message || "invalid UUID"));
206
+ }
207
+ }
208
+
209
+ function _enrollmentId(s, label) {
210
+ if (typeof s !== "string" || !s.length) {
211
+ throw new TypeError("wishlistDigest: " + (label || "enrollment_id") + " must be a non-empty string");
212
+ }
213
+ return s;
214
+ }
215
+
216
+ function _epochMs(n, label) {
217
+ if (!Number.isInteger(n) || n < 0) {
218
+ throw new TypeError("wishlistDigest: " + label + " must be a non-negative integer (epoch ms)");
219
+ }
220
+ return n;
221
+ }
222
+
223
+ function _optionalEpochMs(n, label) {
224
+ if (n == null) return null;
225
+ return _epochMs(n, label);
226
+ }
227
+
228
+ function _batchSize(n) {
229
+ if (n == null) return DEFAULT_BATCH_SIZE;
230
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
231
+ throw new TypeError("wishlistDigest: batch_size must be an integer in [1, " + MAX_BATCH_SIZE + "]");
232
+ }
233
+ return n;
234
+ }
235
+
236
+ function _limit(n) {
237
+ if (n == null) return DEFAULT_LIMIT;
238
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
239
+ throw new TypeError("wishlistDigest: limit must be an integer in [1, " + MAX_LIMIT + "]");
240
+ }
241
+ return n;
242
+ }
243
+
244
+ function _pauseReason(s) {
245
+ if (typeof s !== "string" || !s.length) {
246
+ throw new TypeError("wishlistDigest: reason must be a non-empty string");
247
+ }
248
+ if (s.length > MAX_PAUSE_REASON) {
249
+ throw new TypeError("wishlistDigest: reason must be <= " + MAX_PAUSE_REASON + " characters");
250
+ }
251
+ return s;
252
+ }
253
+
254
+ function _itemCount(n) {
255
+ if (!Number.isInteger(n) || n < 0) {
256
+ throw new TypeError("wishlistDigest: item_count must be a non-negative integer");
257
+ }
258
+ return n;
259
+ }
260
+
261
+ // ---- timezone-aware calendar math ---------------------------------------
262
+ //
263
+ // Same posture as business-hours.js — Intl handles DST automatically, no
264
+ // manual offset math. The dispatcher needs the next wall-clock instant
265
+ // matching the schedule (weekly_dow, monthly_dom, time_local) AFTER a
266
+ // given anchor epoch. The algorithm:
267
+ //
268
+ // 1. Format the anchor in the schedule's timezone -> {y, m, d, hh, mm, weekday}.
269
+ // 2. Roll forward day-by-day to the next matching weekday / day_of_month.
270
+ // 3. If the candidate day === anchor day AND time_local <= anchor wall
271
+ // time, roll forward one more period (weekly = +7d, monthly = next
272
+ // month's day_of_month).
273
+ // 4. Resolve the (YYYY-MM-DD, HH:MM, tz) tuple back to an epoch via
274
+ // the wall-clock-to-epoch refinement loop.
275
+
276
+ function _wallClockIn(tz, epochMs) {
277
+ var fmt = new Intl.DateTimeFormat("en-US", {
278
+ timeZone: tz,
279
+ year: "numeric",
280
+ month: "2-digit",
281
+ day: "2-digit",
282
+ hour: "2-digit",
283
+ minute: "2-digit",
284
+ hour12: false,
285
+ weekday: "short",
286
+ });
287
+ var parts = fmt.formatToParts(new Date(epochMs));
288
+ var out = { y: 0, m: 0, d: 0, hh: 0, mm: 0, weekday: 0 };
289
+ for (var i = 0; i < parts.length; i += 1) {
290
+ var p = parts[i];
291
+ if (p.type === "year") out.y = Number(p.value);
292
+ if (p.type === "month") out.m = Number(p.value);
293
+ if (p.type === "day") out.d = Number(p.value);
294
+ if (p.type === "hour") {
295
+ var hh = Number(p.value);
296
+ out.hh = hh === 24 ? 0 : hh;
297
+ }
298
+ if (p.type === "minute") out.mm = Number(p.value);
299
+ if (p.type === "weekday") {
300
+ var map = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
301
+ out.weekday = map[p.value] != null ? map[p.value] : 0;
302
+ }
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function _pad2(n) { return n < 10 ? "0" + n : String(n); }
308
+
309
+ function _addCalendarDays(ymd, n) {
310
+ var ts = Date.UTC(ymd.y, ymd.m - 1, ymd.d) + n * DAY_MS;
311
+ var dt = new Date(ts);
312
+ return { y: dt.getUTCFullYear(), m: dt.getUTCMonth() + 1, d: dt.getUTCDate() };
313
+ }
314
+
315
+ function _weekdayOfYMD(ymd) {
316
+ var ts = Date.UTC(ymd.y, ymd.m - 1, ymd.d);
317
+ return new Date(ts).getUTCDay();
318
+ }
319
+
320
+ // Resolve a (YYYY-MM-DD, HH:MM, IANA tz) tuple back to an epoch-ms via
321
+ // the same two-pass refinement business-hours.js uses (handles DST
322
+ // transitions where the first guess lands on the wrong side of the
323
+ // fold).
324
+ function _wallClockToEpochMs(tz, ymd, hh, mm) {
325
+ var guess = Date.UTC(ymd.y, ymd.m - 1, ymd.d, hh, mm);
326
+ for (var pass = 0; pass < 2; pass += 1) {
327
+ var wall = _wallClockIn(tz, guess);
328
+ var actualUtcOfWall = Date.UTC(wall.y, wall.m - 1, wall.d, wall.hh, wall.mm);
329
+ var desiredUtcOfWall = Date.UTC(ymd.y, ymd.m - 1, ymd.d, hh, mm);
330
+ var delta = desiredUtcOfWall - actualUtcOfWall;
331
+ if (delta === 0) return guess;
332
+ guess += delta;
333
+ }
334
+ return guess;
335
+ }
336
+
337
+ // Compute the next wall-clock instant strictly AFTER `anchorEpoch` that
338
+ // matches the schedule's (frequency, day_of_week|day_of_month, time_local)
339
+ // in its timezone.
340
+ function _nextDispatchAt(schedule, anchorEpoch) {
341
+ var tz = schedule.timezone;
342
+ var hh = Number(schedule.time_local.slice(0, 2));
343
+ var mm = Number(schedule.time_local.slice(3, 5));
344
+
345
+ var anchorWall = _wallClockIn(tz, anchorEpoch);
346
+ var candidate = { y: anchorWall.y, m: anchorWall.m, d: anchorWall.d };
347
+
348
+ if (schedule.frequency === "weekly") {
349
+ // Roll forward 0..6 days to land on the target weekday.
350
+ var targetDow = schedule.day_of_week;
351
+ var rolls = 0;
352
+ while (_weekdayOfYMD(candidate) !== targetDow && rolls < 8) {
353
+ candidate = _addCalendarDays(candidate, 1);
354
+ rolls += 1;
355
+ }
356
+ // If candidate is the same day as the anchor wall, and the local
357
+ // time has already passed today, roll forward one week.
358
+ var sameDay = (candidate.y === anchorWall.y) &&
359
+ (candidate.m === anchorWall.m) &&
360
+ (candidate.d === anchorWall.d);
361
+ if (sameDay) {
362
+ var pastToday = (anchorWall.hh > hh) ||
363
+ (anchorWall.hh === hh && anchorWall.mm >= mm);
364
+ if (pastToday) candidate = _addCalendarDays(candidate, 7);
365
+ }
366
+ } else {
367
+ // monthly — find the next month-instance of day_of_month at or
368
+ // after the anchor. If the anchor day is before day_of_month, the
369
+ // current month wins; else roll to next month's day_of_month.
370
+ var dom = schedule.day_of_month;
371
+ if (anchorWall.d < dom) {
372
+ candidate = { y: anchorWall.y, m: anchorWall.m, d: dom };
373
+ } else if (anchorWall.d === dom) {
374
+ // Same day — if time has already passed, roll to next month;
375
+ // else fire today.
376
+ var pastTodayM = (anchorWall.hh > hh) ||
377
+ (anchorWall.hh === hh && anchorWall.mm >= mm);
378
+ if (pastTodayM) {
379
+ // Date.UTC interprets month 0-based; passing the 1-based
380
+ // `anchorWall.m` rolls forward exactly one month.
381
+ var rolledTs = Date.UTC(anchorWall.y, anchorWall.m, dom);
382
+ var rolledDt = new Date(rolledTs);
383
+ candidate = {
384
+ y: rolledDt.getUTCFullYear(),
385
+ m: rolledDt.getUTCMonth() + 1,
386
+ d: dom,
387
+ };
388
+ } else {
389
+ candidate = { y: anchorWall.y, m: anchorWall.m, d: dom };
390
+ }
391
+ } else {
392
+ // Anchor day is after day_of_month — next month.
393
+ var rolledTs2 = Date.UTC(anchorWall.y, anchorWall.m, dom);
394
+ var rolledDt2 = new Date(rolledTs2);
395
+ candidate = {
396
+ y: rolledDt2.getUTCFullYear(),
397
+ m: rolledDt2.getUTCMonth() + 1,
398
+ d: dom,
399
+ };
400
+ }
401
+ }
402
+
403
+ return _wallClockToEpochMs(tz, candidate, hh, mm);
404
+ }
405
+
406
+ // Advance an existing next_dispatch_at by one period (called on
407
+ // successful send). Different from _nextDispatchAt: this one snaps to
408
+ // "exactly one period after the previous next_dispatch_at" so the
409
+ // cadence stays on its target weekday / day-of-month even if the
410
+ // dispatcher tick fired late.
411
+ function _advanceByOnePeriod(schedule, currentNext) {
412
+ // The previous next_dispatch_at IS already aligned to the schedule.
413
+ // Adding (frequency-dependent) period and re-resolving keeps the
414
+ // wall-clock time / weekday / day-of-month invariant.
415
+ var tz = schedule.timezone;
416
+ var hh = Number(schedule.time_local.slice(0, 2));
417
+ var mm = Number(schedule.time_local.slice(3, 5));
418
+ var wall = _wallClockIn(tz, currentNext);
419
+ var ymd;
420
+ if (schedule.frequency === "weekly") {
421
+ ymd = _addCalendarDays({ y: wall.y, m: wall.m, d: wall.d }, 7);
422
+ } else {
423
+ var rolledTs = Date.UTC(wall.y, wall.m, schedule.day_of_month); // m (1-based) used unmodified rolls forward exactly one month
424
+ var rolledDt = new Date(rolledTs);
425
+ ymd = {
426
+ y: rolledDt.getUTCFullYear(),
427
+ m: rolledDt.getUTCMonth() + 1,
428
+ d: schedule.day_of_month,
429
+ };
430
+ }
431
+ return _wallClockToEpochMs(tz, ymd, hh, mm);
432
+ }
433
+
434
+ // ---- factory ------------------------------------------------------------
435
+
436
+ function create(opts) {
437
+ opts = opts || {};
438
+
439
+ var query = opts.query;
440
+ if (!query) {
441
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
442
+ }
443
+
444
+ // Required composed handles. `wishlist`, `catalog`, `email` are
445
+ // structural — composeDigest + dispatchTick can't function without
446
+ // them. `emailSuppressions` is optional; when wired the dispatcher
447
+ // short-circuits sends to suppressed addresses.
448
+ var wishlist = opts.wishlist;
449
+ if (!wishlist || typeof wishlist.listForCustomer !== "function") {
450
+ throw new TypeError("wishlistDigest.create: opts.wishlist must expose listForCustomer (the wishlist primitive)");
451
+ }
452
+ var catalog = opts.catalog;
453
+ if (!catalog
454
+ || !catalog.products || typeof catalog.products.get !== "function"
455
+ || !catalog.prices || typeof catalog.prices.current !== "function") {
456
+ throw new TypeError(
457
+ "wishlistDigest.create: opts.catalog must expose products.get + prices.current (the catalog primitive)"
458
+ );
459
+ }
460
+ var email = opts.email;
461
+ if (!email || typeof email.sendWishlistDigest !== "function") {
462
+ throw new TypeError("wishlistDigest.create: opts.email must expose sendWishlistDigest");
463
+ }
464
+ var emailSuppressions = opts.emailSuppressions || null;
465
+ if (emailSuppressions && typeof emailSuppressions.isSuppressed !== "function") {
466
+ throw new TypeError("wishlistDigest.create: opts.emailSuppressions must expose isSuppressed when wired");
467
+ }
468
+
469
+ // Default currency + optional per-customer resolver — mirror the
470
+ // wishlist-alerts posture so a multi-currency operator can inject a
471
+ // per-customer override at construction time.
472
+ var defaultCurrency = typeof opts.defaultCurrency === "string" && /^[A-Z]{3}$/.test(opts.defaultCurrency)
473
+ ? opts.defaultCurrency
474
+ : "USD";
475
+ var currencyForCustomer = typeof opts.currencyForCustomer === "function" ? opts.currencyForCustomer : null;
476
+
477
+ // Resolve the customer's email address. Composed once at factory
478
+ // time so dispatchTick can short-circuit when the operator hasn't
479
+ // wired the resolver.
480
+ var emailForCustomer = typeof opts.emailForCustomer === "function" ? opts.emailForCustomer : null;
481
+
482
+ // ---- per-factory monotonic clock ---------------------------------
483
+ //
484
+ // Two sent-ledger rows / enrollments inside the same wall-clock
485
+ // millisecond tie on (next_dispatch_at, id) — the dispatcher's
486
+ // batch read would return non-deterministic order. Monotonic step
487
+ // guarantees strict-increase.
488
+ var _lastTs = 0;
489
+ function _now(epochOpt) {
490
+ var wall = epochOpt != null ? epochOpt : Date.now();
491
+ if (wall > _lastTs) _lastTs = wall;
492
+ else _lastTs += 1;
493
+ return _lastTs;
494
+ }
495
+
496
+ // ---- internal shapers --------------------------------------------
497
+
498
+ function _shapeSchedule(row) {
499
+ if (!row) return null;
500
+ return {
501
+ slug: row.slug,
502
+ frequency: row.frequency,
503
+ day_of_week: row.day_of_week == null ? null : Number(row.day_of_week),
504
+ day_of_month: row.day_of_month == null ? null : Number(row.day_of_month),
505
+ time_local: row.time_local,
506
+ timezone: row.timezone,
507
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
508
+ created_at: Number(row.created_at),
509
+ updated_at: Number(row.updated_at),
510
+ };
511
+ }
512
+
513
+ function _shapeEnrollment(row) {
514
+ if (!row) return null;
515
+ return {
516
+ id: row.id,
517
+ customer_id: row.customer_id,
518
+ schedule_slug: row.schedule_slug,
519
+ status: row.status,
520
+ next_dispatch_at: Number(row.next_dispatch_at),
521
+ paused_reason: row.paused_reason || null,
522
+ paused_at: row.paused_at == null ? null : Number(row.paused_at),
523
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
524
+ created_at: Number(row.created_at),
525
+ };
526
+ }
527
+
528
+ function _shapeSent(row) {
529
+ if (!row) return null;
530
+ return {
531
+ id: row.id,
532
+ enrollment_id: row.enrollment_id,
533
+ item_count: Number(row.item_count),
534
+ sent_at: Number(row.sent_at),
535
+ };
536
+ }
537
+
538
+ async function _getScheduleBySlug(slug) {
539
+ var r = await query(
540
+ "SELECT * FROM wishlist_digest_schedules WHERE slug = ?1 LIMIT 1",
541
+ [slug],
542
+ );
543
+ return r.rows[0] || null;
544
+ }
545
+
546
+ async function _getEnrollmentById(id) {
547
+ var r = await query(
548
+ "SELECT * FROM wishlist_digest_enrollments WHERE id = ?1 LIMIT 1",
549
+ [id],
550
+ );
551
+ return r.rows[0] || null;
552
+ }
553
+
554
+ async function _getEnrollmentByPair(customerId, scheduleSlug) {
555
+ var r = await query(
556
+ "SELECT * FROM wishlist_digest_enrollments WHERE customer_id = ?1 AND schedule_slug = ?2 LIMIT 1",
557
+ [customerId, scheduleSlug],
558
+ );
559
+ return r.rows[0] || null;
560
+ }
561
+
562
+ async function _resolveCurrency(customerId) {
563
+ if (currencyForCustomer) {
564
+ try {
565
+ var c = await currencyForCustomer(customerId);
566
+ if (typeof c === "string" && /^[A-Z]{3}$/.test(c)) return c;
567
+ } catch (_e) {
568
+ // drop-silent — fall back to the primitive-wide default
569
+ }
570
+ }
571
+ return defaultCurrency;
572
+ }
573
+
574
+ // ---- defineSchedule ----------------------------------------------
575
+
576
+ async function defineSchedule(input) {
577
+ if (!input || typeof input !== "object") {
578
+ throw new TypeError("wishlistDigest.defineSchedule: input object required");
579
+ }
580
+ var slug = _slug(input.slug, "slug");
581
+ var frequency = _frequency(input.frequency);
582
+ var timeLocal = _timeLocal(input.time_local);
583
+ var timezone = _timezone(input.timezone);
584
+
585
+ var dayOfWeek = null;
586
+ var dayOfMonth = null;
587
+ if (frequency === "weekly") {
588
+ if (input.day_of_week == null) {
589
+ throw new TypeError("wishlistDigest.defineSchedule: day_of_week required when frequency = weekly");
590
+ }
591
+ if (input.day_of_month != null) {
592
+ throw new TypeError("wishlistDigest.defineSchedule: day_of_month must be null when frequency = weekly");
593
+ }
594
+ dayOfWeek = _dayOfWeek(input.day_of_week);
595
+ } else {
596
+ if (input.day_of_month == null) {
597
+ throw new TypeError("wishlistDigest.defineSchedule: day_of_month required when frequency = monthly");
598
+ }
599
+ if (input.day_of_week != null) {
600
+ throw new TypeError("wishlistDigest.defineSchedule: day_of_week must be null when frequency = monthly");
601
+ }
602
+ dayOfMonth = _dayOfMonth(input.day_of_month);
603
+ }
604
+
605
+ var existing = await _getScheduleBySlug(slug);
606
+ var ts = _now();
607
+ if (existing) {
608
+ if (existing.archived_at != null) {
609
+ throw new TypeError("wishlistDigest.defineSchedule: schedule " + JSON.stringify(slug) +
610
+ " is archived — operators reauthor by archiving + a fresh slug");
611
+ }
612
+ await query(
613
+ "UPDATE wishlist_digest_schedules SET frequency = ?1, day_of_week = ?2, day_of_month = ?3, " +
614
+ "time_local = ?4, timezone = ?5, updated_at = ?6 WHERE slug = ?7",
615
+ [frequency, dayOfWeek, dayOfMonth, timeLocal, timezone, ts, slug],
616
+ );
617
+ } else {
618
+ await query(
619
+ "INSERT INTO wishlist_digest_schedules " +
620
+ "(slug, frequency, day_of_week, day_of_month, time_local, timezone, archived_at, created_at, updated_at) " +
621
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
622
+ [slug, frequency, dayOfWeek, dayOfMonth, timeLocal, timezone, ts],
623
+ );
624
+ }
625
+ return _shapeSchedule(await _getScheduleBySlug(slug));
626
+ }
627
+
628
+ async function getSchedule(slug) {
629
+ _slug(slug, "slug");
630
+ return _shapeSchedule(await _getScheduleBySlug(slug));
631
+ }
632
+
633
+ // ---- enrollCustomer ----------------------------------------------
634
+
635
+ async function enrollCustomer(input) {
636
+ if (!input || typeof input !== "object") {
637
+ throw new TypeError("wishlistDigest.enrollCustomer: input object required");
638
+ }
639
+ var customerId = _customerId(input.customer_id);
640
+ var scheduleSlug = _slug(input.schedule_slug, "schedule_slug");
641
+ var nowOpt = _optionalEpochMs(input.now, "now");
642
+
643
+ var scheduleRow = await _getScheduleBySlug(scheduleSlug);
644
+ if (!scheduleRow) {
645
+ throw new TypeError("wishlistDigest.enrollCustomer: schedule " + JSON.stringify(scheduleSlug) +
646
+ " not found — call defineSchedule first");
647
+ }
648
+ if (scheduleRow.archived_at != null) {
649
+ throw new TypeError("wishlistDigest.enrollCustomer: schedule " + JSON.stringify(scheduleSlug) + " is archived");
650
+ }
651
+ var schedule = _shapeSchedule(scheduleRow);
652
+
653
+ var anchor = nowOpt != null ? nowOpt : _now();
654
+ var nextDispatchAt = _nextDispatchAt(schedule, anchor);
655
+
656
+ var existing = await _getEnrollmentByPair(customerId, scheduleSlug);
657
+ if (existing) {
658
+ if (existing.status === "cancelled") {
659
+ throw new TypeError("wishlistDigest.enrollCustomer: enrollment is cancelled — " +
660
+ "resumeEnrollment is refused on cancelled rows; operators reauthor only via a fresh customer signal");
661
+ }
662
+ // Already active or paused — refresh next_dispatch_at and unset
663
+ // pause state. An operator re-issuing enrollCustomer for a paused
664
+ // row is effectively resuming, so honour that without forcing a
665
+ // separate resume call.
666
+ await query(
667
+ "UPDATE wishlist_digest_enrollments SET status = 'active', next_dispatch_at = ?1, " +
668
+ "paused_reason = NULL, paused_at = NULL WHERE id = ?2",
669
+ [nextDispatchAt, existing.id],
670
+ );
671
+ return _shapeEnrollment(await _getEnrollmentById(existing.id));
672
+ }
673
+
674
+ var id = _b().uuid.v7();
675
+ var createdAt = _now();
676
+ await query(
677
+ "INSERT INTO wishlist_digest_enrollments " +
678
+ "(id, customer_id, schedule_slug, status, next_dispatch_at, paused_reason, paused_at, cancelled_at, created_at) " +
679
+ "VALUES (?1, ?2, ?3, 'active', ?4, NULL, NULL, NULL, ?5)",
680
+ [id, customerId, scheduleSlug, nextDispatchAt, createdAt],
681
+ );
682
+ return _shapeEnrollment(await _getEnrollmentById(id));
683
+ }
684
+
685
+ // ---- pause / resume / enrollment reads ---------------------------
686
+
687
+ async function pauseEnrollment(input) {
688
+ if (!input || typeof input !== "object") {
689
+ throw new TypeError("wishlistDigest.pauseEnrollment: input object required");
690
+ }
691
+ var enrollmentId = _enrollmentId(input.enrollment_id);
692
+ var reason = _pauseReason(input.reason);
693
+
694
+ var existing = await _getEnrollmentById(enrollmentId);
695
+ if (!existing) {
696
+ throw new TypeError("wishlistDigest.pauseEnrollment: enrollment_id " +
697
+ JSON.stringify(enrollmentId) + " not found");
698
+ }
699
+ if (existing.status === "cancelled") {
700
+ throw new TypeError("wishlistDigest.pauseEnrollment: enrollment is cancelled — cannot pause");
701
+ }
702
+ if (existing.status === "paused") {
703
+ return _shapeEnrollment(existing);
704
+ }
705
+ var ts = _now();
706
+ await query(
707
+ "UPDATE wishlist_digest_enrollments SET status = 'paused', paused_reason = ?1, paused_at = ?2 " +
708
+ "WHERE id = ?3",
709
+ [reason, ts, enrollmentId],
710
+ );
711
+ return _shapeEnrollment(await _getEnrollmentById(enrollmentId));
712
+ }
713
+
714
+ async function resumeEnrollment(input) {
715
+ if (!input || typeof input !== "object") {
716
+ throw new TypeError("wishlistDigest.resumeEnrollment: input object required");
717
+ }
718
+ var enrollmentId = _enrollmentId(input.enrollment_id);
719
+ var nowOpt = _optionalEpochMs(input.now, "now");
720
+
721
+ var existing = await _getEnrollmentById(enrollmentId);
722
+ if (!existing) {
723
+ throw new TypeError("wishlistDigest.resumeEnrollment: enrollment_id " +
724
+ JSON.stringify(enrollmentId) + " not found");
725
+ }
726
+ if (existing.status === "cancelled") {
727
+ throw new TypeError("wishlistDigest.resumeEnrollment: enrollment is cancelled — cannot resume");
728
+ }
729
+ if (existing.status === "active") {
730
+ return _shapeEnrollment(existing);
731
+ }
732
+ var scheduleRow = await _getScheduleBySlug(existing.schedule_slug);
733
+ if (!scheduleRow || scheduleRow.archived_at != null) {
734
+ throw new TypeError("wishlistDigest.resumeEnrollment: parent schedule is missing or archived");
735
+ }
736
+ var anchor = nowOpt != null ? nowOpt : _now();
737
+ var nextDispatchAt = _nextDispatchAt(_shapeSchedule(scheduleRow), anchor);
738
+ await query(
739
+ "UPDATE wishlist_digest_enrollments SET status = 'active', next_dispatch_at = ?1, " +
740
+ "paused_reason = NULL, paused_at = NULL WHERE id = ?2",
741
+ [nextDispatchAt, enrollmentId],
742
+ );
743
+ return _shapeEnrollment(await _getEnrollmentById(enrollmentId));
744
+ }
745
+
746
+ async function enrollmentsForCustomer(customerId) {
747
+ var cid = _customerId(customerId);
748
+ var r = await query(
749
+ "SELECT * FROM wishlist_digest_enrollments WHERE customer_id = ?1 " +
750
+ "ORDER BY created_at DESC, id DESC LIMIT ?2",
751
+ [cid, MAX_LIMIT],
752
+ );
753
+ var out = [];
754
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_shapeEnrollment(r.rows[i]));
755
+ return out;
756
+ }
757
+
758
+ // ---- composeDigest ------------------------------------------------
759
+ //
760
+ // Pure-read shape: walks the customer's wishlist via the composed
761
+ // wishlist primitive, resolves each entry through catalog.products.get
762
+ // + catalog.prices.current, and renders an HTML + text body. The
763
+ // dispatcher uses this for the actual send; an operator can call it
764
+ // directly to preview the next digest.
765
+
766
+ async function composeDigest(input) {
767
+ if (!input || typeof input !== "object") {
768
+ throw new TypeError("wishlistDigest.composeDigest: input object required");
769
+ }
770
+ var customerId = _customerId(input.customer_id);
771
+ var currency = await _resolveCurrency(customerId);
772
+
773
+ var list = await wishlist.listForCustomer(customerId, { limit: MAX_DIGEST_LINES });
774
+ var rows = (list && list.rows) || [];
775
+
776
+ var lines = [];
777
+ for (var i = 0; i < rows.length; i += 1) {
778
+ var entry = rows[i];
779
+ var product = null;
780
+ try { product = await catalog.products.get(entry.product_id); }
781
+ catch (_e) { product = null; }
782
+ var title = (product && typeof product.title === "string" && product.title.length)
783
+ ? product.title
784
+ : entry.product_id;
785
+
786
+ var price = null;
787
+ if (entry.variant_id) {
788
+ try { price = await catalog.prices.current(entry.variant_id, currency); }
789
+ catch (_e2) { price = null; }
790
+ }
791
+ var priceStr = "—";
792
+ if (price && typeof price.amount_minor === "number") {
793
+ priceStr = (price.amount_minor / 100).toFixed(2) + " " + (price.currency || currency);
794
+ }
795
+
796
+ // Stock change — best-effort. The catalog dep doesn't guarantee
797
+ // an inventory handle; when absent, the digest omits the stock
798
+ // marker and the operator's email template gets a plain price.
799
+ var inStock = null;
800
+ if (catalog.inventory && typeof catalog.inventory.get === "function") {
801
+ try {
802
+ var inv = await catalog.inventory.get(entry.variant_id || entry.product_id);
803
+ if (inv) {
804
+ var avail = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
805
+ inStock = avail > 0;
806
+ }
807
+ } catch (_e3) { /* drop-silent — stock is cosmetic */ }
808
+ }
809
+
810
+ lines.push({
811
+ product_id: entry.product_id,
812
+ variant_id: entry.variant_id || null,
813
+ title: title,
814
+ price: priceStr,
815
+ in_stock: inStock,
816
+ product_url: "/products/" + entry.product_id,
817
+ });
818
+ }
819
+
820
+ // Render the HTML + text bodies. Operator-customisable rendering
821
+ // lives in the email primitive's `sendWishlistDigest` handle; the
822
+ // body returned here is a default the dispatcher hands the mailer
823
+ // as a fallback (and the admin-preview shows verbatim).
824
+ var htmlBody = "<h1>Your wishlist this period</h1>";
825
+ var textBody = "Your wishlist this period\n=========================\n";
826
+ if (lines.length === 0) {
827
+ htmlBody += "<p>No items in your wishlist right now.</p>";
828
+ textBody += "\nNo items in your wishlist right now.\n";
829
+ } else {
830
+ htmlBody += "<ul>";
831
+ for (var j = 0; j < lines.length; j += 1) {
832
+ var ln = lines[j];
833
+ var stockBadge = ln.in_stock === true ? " (in stock)"
834
+ : ln.in_stock === false ? " (out of stock)"
835
+ : "";
836
+ htmlBody += "<li><a href=\"" + _htmlEscape(ln.product_url) + "\">" +
837
+ _htmlEscape(ln.title) + "</a> — " +
838
+ _htmlEscape(ln.price) + _htmlEscape(stockBadge) + "</li>";
839
+ textBody += "- " + ln.title + " — " + ln.price + stockBadge + "\n " + ln.product_url + "\n";
840
+ }
841
+ htmlBody += "</ul>";
842
+ }
843
+
844
+ return {
845
+ customer_id: customerId,
846
+ currency: currency,
847
+ item_count: lines.length,
848
+ lines: lines,
849
+ html: htmlBody,
850
+ text: textBody,
851
+ };
852
+ }
853
+
854
+ // ---- dispatchTick ------------------------------------------------
855
+
856
+ async function dispatchTick(input) {
857
+ input = input || {};
858
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
859
+ var batchSize = _batchSize(input.batch_size);
860
+
861
+ var due = await query(
862
+ "SELECT * FROM wishlist_digest_enrollments " +
863
+ "WHERE status = 'active' AND next_dispatch_at <= ?1 " +
864
+ "ORDER BY next_dispatch_at ASC, id ASC LIMIT ?2",
865
+ [now, batchSize],
866
+ );
867
+
868
+ var dispatched = [];
869
+ var skipped = 0;
870
+ var skippedBy = { schedule_missing: 0, no_email: 0, suppressed: 0, email_dispatch_failed: 0 };
871
+
872
+ for (var i = 0; i < due.rows.length; i += 1) {
873
+ var enrollment = due.rows[i];
874
+ var scheduleRow = await _getScheduleBySlug(enrollment.schedule_slug);
875
+ if (!scheduleRow || scheduleRow.archived_at != null) {
876
+ // Schedule archived after enrollment — pause the enrollment so
877
+ // the dispatcher stops re-walking it; the operator's metrics
878
+ // show paused enrollments separately from active ones. A pause
879
+ // (not cancel) so a future resume can re-arm if the operator
880
+ // restores the schedule.
881
+ var pauseTs = _now();
882
+ await query(
883
+ "UPDATE wishlist_digest_enrollments SET status = 'paused', " +
884
+ "paused_reason = 'schedule-archived-or-missing', paused_at = ?1 WHERE id = ?2",
885
+ [pauseTs, enrollment.id],
886
+ );
887
+ skipped += 1; skippedBy.schedule_missing += 1;
888
+ continue;
889
+ }
890
+ var schedule = _shapeSchedule(scheduleRow);
891
+
892
+ // Compose the digest body. composeDigest does its own reads
893
+ // against wishlist + catalog; failures inside it bubble up here
894
+ // and the dispatcher logs the row as skipped so a flapping
895
+ // catalog read doesn't poison the rest of the batch.
896
+ var digest = null;
897
+ try { digest = await composeDigest({ customer_id: enrollment.customer_id }); }
898
+ catch (_e) {
899
+ skipped += 1; skippedBy.email_dispatch_failed += 1;
900
+ continue;
901
+ }
902
+
903
+ // Resolve the customer's email. Absent → skipped no_email; the
904
+ // enrollment stays active so a future tick (after the operator
905
+ // wires emailForCustomer or backfills the customer's email) re-
906
+ // attempts.
907
+ var customerEmail = null;
908
+ if (emailForCustomer) {
909
+ try { customerEmail = await emailForCustomer(enrollment.customer_id); }
910
+ catch (_e2) { customerEmail = null; }
911
+ }
912
+ if (typeof customerEmail !== "string" || !customerEmail.length) {
913
+ skipped += 1; skippedBy.no_email += 1;
914
+ continue;
915
+ }
916
+
917
+ // Suppressions short-circuit. When wired, an address on the
918
+ // suppress list ledgers a row with item_count = 0 + advances
919
+ // next_dispatch_at, so the cadence stays on rails but no email
920
+ // fires (operator's metrics still surface the customer as
921
+ // "enrolled but suppressed").
922
+ var isSuppressed = false;
923
+ if (emailSuppressions) {
924
+ try {
925
+ var supView = await emailSuppressions.isSuppressed({
926
+ email: customerEmail,
927
+ scope: "marketing",
928
+ });
929
+ isSuppressed = !!(supView && supView.suppressed === true);
930
+ } catch (_e3) { isSuppressed = false; }
931
+ }
932
+
933
+ var sentItemCount = isSuppressed ? 0 : digest.item_count;
934
+ if (!isSuppressed) {
935
+ try {
936
+ await email.sendWishlistDigest({
937
+ customer_email: customerEmail,
938
+ schedule_slug: enrollment.schedule_slug,
939
+ currency: digest.currency,
940
+ item_count: digest.item_count,
941
+ lines: digest.lines,
942
+ html: digest.html,
943
+ text: digest.text,
944
+ });
945
+ } catch (_e4) {
946
+ skipped += 1; skippedBy.email_dispatch_failed += 1;
947
+ continue;
948
+ }
949
+ } else {
950
+ skipped += 1; skippedBy.suppressed += 1;
951
+ // Still ledger + advance — the cadence stays on rails, but the
952
+ // attempt isn't counted as a successful send.
953
+ }
954
+
955
+ // Ledger + advance (both branches — suppressed cadence stays on
956
+ // rails so the customer sees their next-dispatch-ETA roll forward).
957
+ var sentId = _b().uuid.v7();
958
+ var sentAt = _now(now);
959
+ await query(
960
+ "INSERT INTO wishlist_digest_sent (id, enrollment_id, item_count, sent_at) " +
961
+ "VALUES (?1, ?2, ?3, ?4)",
962
+ [sentId, enrollment.id, sentItemCount, sentAt],
963
+ );
964
+ var nextDispatchAt = _advanceByOnePeriod(schedule, Number(enrollment.next_dispatch_at));
965
+ await query(
966
+ "UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 WHERE id = ?2",
967
+ [nextDispatchAt, enrollment.id],
968
+ );
969
+ if (!isSuppressed) {
970
+ dispatched.push({
971
+ id: sentId,
972
+ enrollment_id: enrollment.id,
973
+ item_count: sentItemCount,
974
+ sent_at: sentAt,
975
+ suppressed: false,
976
+ next_dispatch_at: nextDispatchAt,
977
+ });
978
+ }
979
+ }
980
+
981
+ return {
982
+ dispatched: dispatched,
983
+ sent: dispatched.length,
984
+ skipped: skipped,
985
+ skipped_by: skippedBy,
986
+ };
987
+ }
988
+
989
+ // ---- recordDigestSent --------------------------------------------
990
+
991
+ async function recordDigestSent(input) {
992
+ if (!input || typeof input !== "object") {
993
+ throw new TypeError("wishlistDigest.recordDigestSent: input object required");
994
+ }
995
+ var enrollmentId = _enrollmentId(input.enrollment_id);
996
+ var itemCount = _itemCount(input.item_count);
997
+ var sentAtOpt = _optionalEpochMs(input.sent_at, "sent_at");
998
+
999
+ var existing = await _getEnrollmentById(enrollmentId);
1000
+ if (!existing) {
1001
+ throw new TypeError("wishlistDigest.recordDigestSent: enrollment_id " +
1002
+ JSON.stringify(enrollmentId) + " not found");
1003
+ }
1004
+
1005
+ var sentAt = sentAtOpt != null ? _now(sentAtOpt) : _now();
1006
+ var sentId = _b().uuid.v7();
1007
+ await query(
1008
+ "INSERT INTO wishlist_digest_sent (id, enrollment_id, item_count, sent_at) " +
1009
+ "VALUES (?1, ?2, ?3, ?4)",
1010
+ [sentId, enrollmentId, itemCount, sentAt],
1011
+ );
1012
+
1013
+ // Advance the cadence — recordDigestSent is the terminal marker
1014
+ // for a successful send regardless of which path (sync via
1015
+ // dispatchTick / async via mailer callback) produced the row.
1016
+ var scheduleRow = await _getScheduleBySlug(existing.schedule_slug);
1017
+ if (scheduleRow && scheduleRow.archived_at == null) {
1018
+ var schedule = _shapeSchedule(scheduleRow);
1019
+ var nextDispatchAt = _advanceByOnePeriod(schedule, Number(existing.next_dispatch_at));
1020
+ await query(
1021
+ "UPDATE wishlist_digest_enrollments SET next_dispatch_at = ?1 WHERE id = ?2",
1022
+ [nextDispatchAt, enrollmentId],
1023
+ );
1024
+ }
1025
+
1026
+ var r = await query("SELECT * FROM wishlist_digest_sent WHERE id = ?1 LIMIT 1", [sentId]);
1027
+ return _shapeSent(r.rows[0]);
1028
+ }
1029
+
1030
+ // ---- metricsForSchedule ------------------------------------------
1031
+
1032
+ async function metricsForSchedule(input) {
1033
+ if (!input || typeof input !== "object") {
1034
+ throw new TypeError("wishlistDigest.metricsForSchedule: input object required");
1035
+ }
1036
+ var slug = _slug(input.slug, "slug");
1037
+ var from = _epochMs(input.from, "from");
1038
+ var to = _epochMs(input.to, "to");
1039
+ if (to < from) {
1040
+ throw new TypeError("wishlistDigest.metricsForSchedule: to must be >= from");
1041
+ }
1042
+ var r = await query(
1043
+ "SELECT COUNT(*) AS n, COALESCE(SUM(item_count), 0) AS items " +
1044
+ "FROM wishlist_digest_sent s " +
1045
+ "JOIN wishlist_digest_enrollments e ON e.id = s.enrollment_id " +
1046
+ "WHERE e.schedule_slug = ?1 AND s.sent_at >= ?2 AND s.sent_at <= ?3",
1047
+ [slug, from, to],
1048
+ );
1049
+ var row = r.rows[0] || {};
1050
+ return {
1051
+ slug: slug,
1052
+ from: from,
1053
+ to: to,
1054
+ sent_count: Number(row.n || 0),
1055
+ total_items: Number(row.items || 0),
1056
+ };
1057
+ }
1058
+
1059
+ return {
1060
+ FREQUENCIES: FREQUENCIES,
1061
+ STATUSES: STATUSES,
1062
+
1063
+ defineSchedule: defineSchedule,
1064
+ getSchedule: getSchedule,
1065
+ enrollCustomer: enrollCustomer,
1066
+ pauseEnrollment: pauseEnrollment,
1067
+ resumeEnrollment: resumeEnrollment,
1068
+ enrollmentsForCustomer:enrollmentsForCustomer,
1069
+ composeDigest: composeDigest,
1070
+ dispatchTick: dispatchTick,
1071
+ recordDigestSent: recordDigestSent,
1072
+ metricsForSchedule: metricsForSchedule,
1073
+ };
1074
+ }
1075
+
1076
+ // Top-level `run()` for direct invocation — exercises the primitive's
1077
+ // factory shape against an in-memory query stub so the smoke caller
1078
+ // can confirm the module loads + the factory composes without touching
1079
+ // a remote D1 or a migration file. The stub is minimal; it covers the
1080
+ // defineSchedule happy path.
1081
+ async function run() {
1082
+ var schedules = {};
1083
+ var q = async function (sql, params) {
1084
+ params = params || [];
1085
+ var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
1086
+ if (verb === "SELECT" && /FROM wishlist_digest_schedules/.test(sql)) {
1087
+ var s = schedules[params[0]];
1088
+ return { rows: s ? [s] : [], rowCount: s ? 1 : 0 };
1089
+ }
1090
+ if (verb === "INSERT" && /wishlist_digest_schedules/.test(sql)) {
1091
+ schedules[params[0]] = {
1092
+ slug: params[0],
1093
+ frequency: params[1],
1094
+ day_of_week: params[2],
1095
+ day_of_month: params[3],
1096
+ time_local: params[4],
1097
+ timezone: params[5],
1098
+ archived_at: null,
1099
+ created_at: params[6],
1100
+ updated_at: params[6],
1101
+ };
1102
+ return { rows: [], rowCount: 1 };
1103
+ }
1104
+ return { rows: [], rowCount: 0 };
1105
+ };
1106
+ var stubWishlist = { listForCustomer: async function () { return { rows: [], nextCursor: null }; } };
1107
+ var stubCatalog = {
1108
+ products: { get: async function () { return null; } },
1109
+ prices: { current: async function () { return null; } },
1110
+ };
1111
+ var stubEmail = { sendWishlistDigest: async function () { return { ok: true }; } };
1112
+ var wd = create({
1113
+ query: q,
1114
+ wishlist: stubWishlist,
1115
+ catalog: stubCatalog,
1116
+ email: stubEmail,
1117
+ });
1118
+ await wd.defineSchedule({
1119
+ slug: "weekly-monday-9am",
1120
+ frequency: "weekly",
1121
+ day_of_week: 1,
1122
+ time_local: "09:00",
1123
+ timezone: "UTC",
1124
+ });
1125
+ return { ok: true };
1126
+ }
1127
+
1128
+ module.exports = {
1129
+ create: create,
1130
+ run: run,
1131
+ FREQUENCIES: FREQUENCIES,
1132
+ STATUSES: STATUSES,
1133
+ };