@blamejs/blamejs-shop 0.0.72 → 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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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, "&")
|
|
142
|
+
.replace(/</g, "<")
|
|
143
|
+
.replace(/>/g, ">")
|
|
144
|
+
.replace(/"/g, """)
|
|
145
|
+
.replace(/'/g, "'");
|
|
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
|
+
};
|