@blamejs/blamejs-shop 0.0.66 → 0.0.72

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 +12 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +36 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/loyalty-earn-rules.js +786 -0
  19. package/lib/operator-roles.js +768 -0
  20. package/lib/order-escalation.js +951 -0
  21. package/lib/order-ratings.js +495 -0
  22. package/lib/order-tags.js +944 -0
  23. package/lib/packing-slips.js +810 -0
  24. package/lib/pixel-events.js +995 -0
  25. package/lib/print-queue.js +681 -0
  26. package/lib/product-qa.js +749 -0
  27. package/lib/promo-bundles.js +835 -0
  28. package/lib/push-notifications.js +937 -0
  29. package/lib/refund-automation.js +853 -0
  30. package/lib/reorder-reminders.js +798 -0
  31. package/lib/robots-config.js +753 -0
  32. package/lib/seller-signup.js +1052 -0
  33. package/lib/sitemap-generator.js +717 -0
  34. package/lib/split-shipments.js +7 -1
  35. package/lib/subscription-gifts.js +710 -0
  36. package/lib/tax-cert-renewals.js +632 -0
  37. package/lib/tier-benefits.js +776 -0
  38. package/lib/vendor/MANIFEST.json +2 -2
  39. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  40. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  41. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  42. package/lib/vendor/blamejs/package.json +1 -1
  43. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  44. package/lib/wishlist-alerts.js +842 -0
  45. package/lib/wishlist-sharing.js +718 -0
  46. package/package.json +1 -1
@@ -0,0 +1,842 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.wishlistAlerts
4
+ * @title Notify customers when wishlist items go on sale or come back
5
+ * in stock.
6
+ *
7
+ * @intro
8
+ * Customers add items to their wishlist (the `wishlist` primitive);
9
+ * operators author one or more alert POLICIES through this primitive
10
+ * ("any price drop of 20%+", "any restock of a sold-out item"); the
11
+ * scheduler walks the wishlist + the catalog + the sent-ledger every
12
+ * N minutes and fires email through the injected `email` handle for
13
+ * each matching (customer, sku, policy) tuple.
14
+ *
15
+ * Surface:
16
+ *
17
+ * - defineAlertPolicy({ slug, trigger, threshold,
18
+ * max_alerts_per_week_per_customer? })
19
+ * Operator-authored policy row. `trigger` is one of
20
+ * `price_drop` / `back_in_stock` / `new_review_high_rating` /
21
+ * `restocked`. `threshold` is a plain-object payload whose
22
+ * validated shape depends on `trigger` — for price_drop it must
23
+ * carry `percent_off_bps_min` (1..10000 basis points; 2000 means
24
+ * "20% off or more"). For the other triggers it is `{}`.
25
+ * Refuses redefinition; operators reauthor by archiving + a
26
+ * fresh defineAlertPolicy.
27
+ *
28
+ * - scanAndDispatch({ now, batch_size? })
29
+ * Scheduler-callable. For each LIVE policy whose trigger has a
30
+ * wired source (price_drop + back_in_stock at v1), walks the
31
+ * wishlist table, resolves each entry's variant + sku + current
32
+ * price / inventory, evaluates the policy's condition, dedupes
33
+ * against the sent-ledger (no second fire for the same
34
+ * (customer, sku, policy) in 24h), respects the per-customer
35
+ * weekly cap, respects opt-outs in
36
+ * `wishlist_alert_unsubscribes`, and hands each match to the
37
+ * injected `email.sendWishlistDiscount` handle. Returns
38
+ * `{ scanned, candidates, sent, skipped }`.
39
+ *
40
+ * - markAlertSent({ alert_id, channel })
41
+ * Operator off-table delivery record — when the operator wires
42
+ * SMS or in-app delivery outside the `email` handle, this
43
+ * writes a wishlist_alerts_sent row for the matched policy /
44
+ * customer / sku without going through scanAndDispatch.
45
+ * `channel` ∈ {email, sms, in-app}.
46
+ *
47
+ * - customerAlertHistory({ customer_id, cursor?, limit? })
48
+ * Account-page surface — every alert the customer has received,
49
+ * newest first, paginated with the standard HMAC-tagged
50
+ * opaque cursor.
51
+ *
52
+ * - unsubscribeFromAlertKind({ customer_id, trigger })
53
+ * Customer opt-out lever; the dispatcher silently skips every
54
+ * row matching (customer_id, trigger) once this is set.
55
+ * Idempotent — re-unsubscribing is a no-op.
56
+ *
57
+ * - metricsForPolicy({ slug, from, to })
58
+ * Operator-dashboard rollup — count of sent alerts per policy
59
+ * in a time window, broken out by channel.
60
+ *
61
+ * Storage: wishlist_alert_policies, wishlist_alerts_sent,
62
+ * wishlist_alert_unsubscribes (migration 0156).
63
+ *
64
+ * Composes:
65
+ * - wishlist (required) — read the saved (customer, product,
66
+ * variant?) tuples.
67
+ * - catalog (required) — resolve variant → sku, current + prior
68
+ * price, current inventory.
69
+ * - email (required) — sendWishlistDiscount for price_drop;
70
+ * sendWishlistDiscount also serves back_in_stock with the
71
+ * price slot rendered as "—" so a single template covers both
72
+ * v1 triggers (operators wiring distinct templates inject a
73
+ * wrapper email handle).
74
+ * - stockAlerts (optional) — when wired, the scanner skips
75
+ * (customer, sku) pairs the customer is independently
76
+ * subscribed to via the PDP "notify me" toggle so the two
77
+ * paths don't double-deliver the same restock signal. Without
78
+ * it, every back_in_stock match fires regardless of stockAlerts
79
+ * state.
80
+ *
81
+ * Monotonic clock: a per-factory monotonic timestamp ensures that
82
+ * two alerts written in the same wall-clock millisecond carry
83
+ * strictly-increasing `occurred_at` values. The
84
+ * `(customer_id, occurred_at DESC)` index then returns the latest
85
+ * alert unambiguously and the keyset-paginated history is stable.
86
+ *
87
+ * @primitive wishlistAlerts
88
+ * @related shop.wishlist, shop.catalog, shop.email, shop.stockAlerts
89
+ */
90
+
91
+ var bShop;
92
+ function _b() {
93
+ if (!bShop) bShop = require("./index");
94
+ return bShop.framework;
95
+ }
96
+
97
+ // ---- constants ----------------------------------------------------------
98
+
99
+ var TRIGGERS = ["price_drop", "back_in_stock", "new_review_high_rating", "restocked"];
100
+ var CHANNELS = ["email", "sms", "in-app"];
101
+
102
+ // Triggers the scanner has a wired source for at v1. The other two
103
+ // trigger kinds (new_review_high_rating + restocked) accept policies
104
+ // at definition time so a forward-compatible policy table can be
105
+ // authored before the matching event-source primitives land; the
106
+ // scanner skips them with `policy_source_unwired` accounted in the
107
+ // `skipped` count and never writes a sent-ledger row.
108
+ var SCANNABLE_TRIGGERS = ["price_drop", "back_in_stock"];
109
+
110
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
111
+ var MAX_SLUG_LEN = 80;
112
+ var MAX_LIST_LIMIT = 200;
113
+ var DEFAULT_LIMIT = 50;
114
+ var DEFAULT_BATCH_SIZE = 500;
115
+ var MAX_BATCH_SIZE = 5000;
116
+ var MAX_WEEKLY_CAP = 1000;
117
+ var MAX_BPS = 10000; // 100% — refuse strictly absurd thresholds
118
+ var MS_PER_DAY = 86400000;
119
+ var MS_PER_WEEK = MS_PER_DAY * 7;
120
+
121
+ // Per-(customer, sku, policy) re-fire suppression window. A price drop
122
+ // that triggers an alert today shouldn't re-fire tomorrow just because
123
+ // the variant's price moved again by a microscopic amount — the
124
+ // dispatcher refuses to write a fresh ledger row inside this window
125
+ // for the same triple.
126
+ var DEDUPE_WINDOW_MS = MS_PER_DAY;
127
+
128
+ var HISTORY_ORDER_KEY = ["occurred_at:desc", "id:desc"];
129
+
130
+ // ---- validators ---------------------------------------------------------
131
+
132
+ function _slug(s) {
133
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
134
+ throw new TypeError("wishlistAlerts: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
135
+ }
136
+ return s;
137
+ }
138
+
139
+ function _trigger(s) {
140
+ if (typeof s !== "string" || TRIGGERS.indexOf(s) === -1) {
141
+ throw new TypeError("wishlistAlerts: trigger must be one of " + TRIGGERS.join(", "));
142
+ }
143
+ return s;
144
+ }
145
+
146
+ function _channel(s) {
147
+ if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
148
+ throw new TypeError("wishlistAlerts: channel must be one of " + CHANNELS.join(", "));
149
+ }
150
+ return s;
151
+ }
152
+
153
+ function _uuid(s, label) {
154
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
155
+ catch (e) { throw new TypeError("wishlistAlerts: " + label + " — " + (e && e.message || "invalid UUID")); }
156
+ }
157
+
158
+ function _now(n) {
159
+ if (n == null) return Date.now();
160
+ if (!Number.isInteger(n) || n < 0) {
161
+ throw new TypeError("wishlistAlerts: now must be a non-negative integer epoch-ms or null");
162
+ }
163
+ return n;
164
+ }
165
+
166
+ function _limit(n, label) {
167
+ if (n == null) return DEFAULT_LIMIT;
168
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
169
+ throw new TypeError("wishlistAlerts: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]");
170
+ }
171
+ return n;
172
+ }
173
+
174
+ function _batchSize(n) {
175
+ if (n == null) return DEFAULT_BATCH_SIZE;
176
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
177
+ throw new TypeError("wishlistAlerts: batch_size must be an integer in [1, " + MAX_BATCH_SIZE + "]");
178
+ }
179
+ return n;
180
+ }
181
+
182
+ function _weeklyCap(n) {
183
+ if (n == null) return null;
184
+ if (!Number.isInteger(n) || n < 1 || n > MAX_WEEKLY_CAP) {
185
+ throw new TypeError("wishlistAlerts: max_alerts_per_week_per_customer must be a positive integer in [1, " + MAX_WEEKLY_CAP + "] or null");
186
+ }
187
+ return n;
188
+ }
189
+
190
+ function _validateThresholdFor(trigger, threshold) {
191
+ if (threshold == null || typeof threshold !== "object" || Array.isArray(threshold)) {
192
+ throw new TypeError("wishlistAlerts: threshold must be a plain object");
193
+ }
194
+ if (trigger === "price_drop") {
195
+ if (!Number.isInteger(threshold.percent_off_bps_min)
196
+ || threshold.percent_off_bps_min < 1
197
+ || threshold.percent_off_bps_min > MAX_BPS) {
198
+ throw new TypeError(
199
+ "wishlistAlerts: threshold.percent_off_bps_min must be an integer in [1, " + MAX_BPS + "] for price_drop "
200
+ + "(basis points; 2000 = 20%)",
201
+ );
202
+ }
203
+ // Refuse extras so a typo can't silently encode a different gate.
204
+ var keys = Object.keys(threshold);
205
+ for (var i = 0; i < keys.length; i += 1) {
206
+ if (keys[i] !== "percent_off_bps_min") {
207
+ throw new TypeError("wishlistAlerts: threshold for price_drop accepts only percent_off_bps_min (got " + JSON.stringify(keys[i]) + ")");
208
+ }
209
+ }
210
+ return { percent_off_bps_min: threshold.percent_off_bps_min };
211
+ }
212
+ // Other triggers — explicit empty-object contract.
213
+ if (Object.keys(threshold).length !== 0) {
214
+ throw new TypeError("wishlistAlerts: threshold for " + trigger + " must be an empty object at v1");
215
+ }
216
+ return {};
217
+ }
218
+
219
+ function _hydratePolicy(r) {
220
+ if (!r) return null;
221
+ var thresh;
222
+ try { thresh = JSON.parse(r.threshold_json); }
223
+ catch (_e) { thresh = {}; }
224
+ return {
225
+ slug: r.slug,
226
+ trigger: r.trigger,
227
+ threshold: thresh,
228
+ max_alerts_per_week_per_customer: r.max_alerts_per_week_per_customer == null
229
+ ? null
230
+ : Number(r.max_alerts_per_week_per_customer),
231
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
232
+ created_at: Number(r.created_at),
233
+ updated_at: Number(r.updated_at),
234
+ };
235
+ }
236
+
237
+ // ---- factory ------------------------------------------------------------
238
+
239
+ function create(opts) {
240
+ opts = opts || {};
241
+ var query = opts.query;
242
+ if (!query) {
243
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
244
+ }
245
+
246
+ // Required composed handles. `wishlist`, `catalog`, and `email` are
247
+ // structural — the scanner can't function without them. `stockAlerts`
248
+ // is optional; when wired the scanner double-checks PDP-subscribed
249
+ // (customer, sku) pairs so back_in_stock doesn't duplicate.
250
+ var wishlist = opts.wishlist;
251
+ if (!wishlist || typeof wishlist.listForCustomer !== "function") {
252
+ throw new TypeError("wishlistAlerts.create: opts.wishlist must expose listForCustomer (the wishlist primitive)");
253
+ }
254
+ var catalog = opts.catalog;
255
+ if (!catalog
256
+ || !catalog.variants || typeof catalog.variants.get !== "function"
257
+ || !catalog.prices || typeof catalog.prices.current !== "function"
258
+ || typeof catalog.prices.history !== "function"
259
+ || !catalog.inventory || typeof catalog.inventory.get !== "function") {
260
+ throw new TypeError(
261
+ "wishlistAlerts.create: opts.catalog must expose variants.get, prices.current, prices.history, "
262
+ + "inventory.get (the catalog primitive)",
263
+ );
264
+ }
265
+ var email = opts.email;
266
+ if (!email || typeof email.sendWishlistDiscount !== "function") {
267
+ throw new TypeError("wishlistAlerts.create: opts.email must expose sendWishlistDiscount (the email primitive)");
268
+ }
269
+ var stockAlerts = opts.stockAlerts || null;
270
+ if (stockAlerts && typeof stockAlerts.isSubscribed !== "function") {
271
+ throw new TypeError("wishlistAlerts.create: opts.stockAlerts must expose isSubscribed when wired");
272
+ }
273
+
274
+ // Pagination cursor secret — same posture as wishlist.create.
275
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
276
+ if (process.env.NODE_ENV === "production") {
277
+ throw new Error("wishlistAlerts.create: opts.cursorSecret is required in production");
278
+ }
279
+ opts.cursorSecret = "wishlist-alerts-cursor-secret-dev-only";
280
+ }
281
+ var cursorSecret = opts.cursorSecret;
282
+
283
+ // Default currency used to resolve catalog.prices.current / history.
284
+ // Operators with multi-currency catalogs inject a function via
285
+ // opts.currencyForCustomer(customer_id) for per-customer resolution;
286
+ // absent, the dispatcher falls back to a primitive-wide default.
287
+ var defaultCurrency = typeof opts.defaultCurrency === "string" && /^[A-Z]{3}$/.test(opts.defaultCurrency)
288
+ ? opts.defaultCurrency
289
+ : "USD";
290
+ var currencyForCustomer = typeof opts.currencyForCustomer === "function" ? opts.currencyForCustomer : null;
291
+
292
+ // Per-factory monotonic clock. Two alerts written against the same
293
+ // (customer, sku) in the same wall-clock millisecond would otherwise
294
+ // tie on `occurred_at` and make the `(customer_id, occurred_at DESC)`
295
+ // index ambiguous. Forward-leap when the wall clock outpaces the
296
+ // counter; otherwise bump by 1ms.
297
+ var _lastTs = 0;
298
+ function _monotonicTs(now) {
299
+ var wall = now != null ? now : Date.now();
300
+ if (wall > _lastTs) _lastTs = wall;
301
+ else _lastTs += 1;
302
+ return _lastTs;
303
+ }
304
+
305
+ // ---- defineAlertPolicy ------------------------------------------------
306
+
307
+ async function defineAlertPolicy(input) {
308
+ if (!input || typeof input !== "object") {
309
+ throw new TypeError("wishlistAlerts.defineAlertPolicy: input object required");
310
+ }
311
+ var slug = _slug(input.slug);
312
+ var trigger = _trigger(input.trigger);
313
+ var threshold = _validateThresholdFor(trigger, input.threshold);
314
+ var weeklyCap = _weeklyCap(input.max_alerts_per_week_per_customer);
315
+
316
+ var existing = (await query(
317
+ "SELECT slug FROM wishlist_alert_policies WHERE slug = ?1 LIMIT 1",
318
+ [slug],
319
+ )).rows[0];
320
+ if (existing) {
321
+ throw new TypeError("wishlistAlerts.defineAlertPolicy: slug " + JSON.stringify(slug) + " already exists - archive + redefine");
322
+ }
323
+
324
+ var ts = _monotonicTs();
325
+ await query(
326
+ "INSERT INTO wishlist_alert_policies "
327
+ + "(slug, trigger, threshold_json, max_alerts_per_week_per_customer, archived_at, created_at, updated_at) "
328
+ + "VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
329
+ [slug, trigger, JSON.stringify(threshold), weeklyCap, ts],
330
+ );
331
+ return await getAlertPolicy(slug);
332
+ }
333
+
334
+ async function getAlertPolicy(slug) {
335
+ _slug(slug);
336
+ var r = (await query(
337
+ "SELECT * FROM wishlist_alert_policies WHERE slug = ?1 LIMIT 1",
338
+ [slug],
339
+ )).rows[0];
340
+ return _hydratePolicy(r);
341
+ }
342
+
343
+ async function listAlertPolicies(listOpts) {
344
+ listOpts = listOpts || {};
345
+ var liveOnly = listOpts.live_only === true;
346
+ var limit = _limit(listOpts.limit, "limit");
347
+ var sql, params;
348
+ if (liveOnly) {
349
+ sql = "SELECT * FROM wishlist_alert_policies WHERE archived_at IS NULL "
350
+ + "ORDER BY created_at ASC, slug ASC LIMIT ?1";
351
+ params = [limit];
352
+ } else {
353
+ sql = "SELECT * FROM wishlist_alert_policies "
354
+ + "ORDER BY created_at ASC, slug ASC LIMIT ?1";
355
+ params = [limit];
356
+ }
357
+ var rows = (await query(sql, params)).rows;
358
+ var out = [];
359
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydratePolicy(rows[i]));
360
+ return out;
361
+ }
362
+
363
+ async function archiveAlertPolicy(slug) {
364
+ _slug(slug);
365
+ var ts = _monotonicTs();
366
+ var r = await query(
367
+ "UPDATE wishlist_alert_policies SET archived_at = ?1, updated_at = ?1 "
368
+ + "WHERE slug = ?2 AND archived_at IS NULL",
369
+ [ts, slug],
370
+ );
371
+ return { archived: Number(r.rowCount || 0) > 0 };
372
+ }
373
+
374
+ // ---- unsubscribe + opt-out --------------------------------------------
375
+
376
+ async function unsubscribeFromAlertKind(input) {
377
+ if (!input || typeof input !== "object") {
378
+ throw new TypeError("wishlistAlerts.unsubscribeFromAlertKind: input object required");
379
+ }
380
+ var customerId = _uuid(input.customer_id, "customer_id");
381
+ var trigger = _trigger(input.trigger);
382
+ var ts = _monotonicTs(input.now);
383
+
384
+ var existing = (await query(
385
+ "SELECT id FROM wishlist_alert_unsubscribes WHERE customer_id = ?1 AND trigger = ?2 LIMIT 1",
386
+ [customerId, trigger],
387
+ )).rows[0];
388
+ if (existing) {
389
+ return { id: existing.id, status: "already-unsubscribed" };
390
+ }
391
+ var id = _b().uuid.v7();
392
+ await query(
393
+ "INSERT INTO wishlist_alert_unsubscribes (id, customer_id, trigger, occurred_at) "
394
+ + "VALUES (?1, ?2, ?3, ?4)",
395
+ [id, customerId, trigger, ts],
396
+ );
397
+ return { id: id, status: "unsubscribed" };
398
+ }
399
+
400
+ async function isUnsubscribedFromTrigger(customerId, trigger) {
401
+ var r = await query(
402
+ "SELECT id FROM wishlist_alert_unsubscribes WHERE customer_id = ?1 AND trigger = ?2 LIMIT 1",
403
+ [customerId, trigger],
404
+ );
405
+ return r.rows.length > 0;
406
+ }
407
+
408
+ // ---- ledger reads -----------------------------------------------------
409
+
410
+ async function customerAlertHistory(input) {
411
+ if (!input || typeof input !== "object") {
412
+ throw new TypeError("wishlistAlerts.customerAlertHistory: input object required");
413
+ }
414
+ var customerId = _uuid(input.customer_id, "customer_id");
415
+ var limit = _limit(input.limit, "limit");
416
+ var cursorVals = null;
417
+ if (input.cursor != null) {
418
+ if (typeof input.cursor !== "string") {
419
+ throw new TypeError("wishlistAlerts.customerAlertHistory: cursor must be an opaque string or null");
420
+ }
421
+ try {
422
+ var state = _b().pagination.decodeCursor(input.cursor, cursorSecret);
423
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(HISTORY_ORDER_KEY)) {
424
+ throw new TypeError("wishlistAlerts.customerAlertHistory: cursor orderKey mismatch");
425
+ }
426
+ cursorVals = state.vals;
427
+ } catch (e) {
428
+ if (e instanceof TypeError) throw e;
429
+ throw new TypeError("wishlistAlerts.customerAlertHistory: cursor — " + (e && e.message || "malformed"));
430
+ }
431
+ }
432
+ var sql, params;
433
+ if (cursorVals) {
434
+ sql = "SELECT * FROM wishlist_alerts_sent WHERE customer_id = ?1 AND "
435
+ + "(occurred_at < ?2 OR (occurred_at = ?2 AND id < ?3)) "
436
+ + "ORDER BY occurred_at DESC, id DESC LIMIT ?4";
437
+ params = [customerId, cursorVals[0], cursorVals[1], limit];
438
+ } else {
439
+ sql = "SELECT * FROM wishlist_alerts_sent WHERE customer_id = ?1 "
440
+ + "ORDER BY occurred_at DESC, id DESC LIMIT ?2";
441
+ params = [customerId, limit];
442
+ }
443
+ var rows = (await query(sql, params)).rows;
444
+ var hydrated = [];
445
+ for (var i = 0; i < rows.length; i += 1) {
446
+ var r = rows[i];
447
+ hydrated.push({
448
+ id: r.id,
449
+ customer_id: r.customer_id,
450
+ policy_slug: r.policy_slug,
451
+ sku: r.sku,
452
+ channel: r.channel,
453
+ occurred_at: Number(r.occurred_at),
454
+ });
455
+ }
456
+ var last = hydrated[hydrated.length - 1];
457
+ var nextCursor = null;
458
+ if (last && hydrated.length === limit) {
459
+ nextCursor = _b().pagination.encodeCursor({
460
+ orderKey: HISTORY_ORDER_KEY,
461
+ vals: [last.occurred_at, last.id],
462
+ forward: true,
463
+ }, cursorSecret);
464
+ }
465
+ return { rows: hydrated, nextCursor: nextCursor };
466
+ }
467
+
468
+ async function metricsForPolicy(input) {
469
+ if (!input || typeof input !== "object") {
470
+ throw new TypeError("wishlistAlerts.metricsForPolicy: input object required");
471
+ }
472
+ var slug = _slug(input.slug);
473
+ if (!Number.isInteger(input.from) || input.from < 0) {
474
+ throw new TypeError("wishlistAlerts.metricsForPolicy: from must be a non-negative integer epoch-ms");
475
+ }
476
+ if (!Number.isInteger(input.to) || input.to < input.from) {
477
+ throw new TypeError("wishlistAlerts.metricsForPolicy: to must be a non-negative integer epoch-ms >= from");
478
+ }
479
+ var r = await query(
480
+ "SELECT channel, COUNT(*) AS n FROM wishlist_alerts_sent "
481
+ + "WHERE policy_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3 "
482
+ + "GROUP BY channel",
483
+ [slug, input.from, input.to],
484
+ );
485
+ var byChannel = { email: 0, sms: 0, "in-app": 0 };
486
+ for (var i = 0; i < r.rows.length; i += 1) {
487
+ var row = r.rows[i];
488
+ byChannel[row.channel] = Number(row.n);
489
+ }
490
+ var total = byChannel.email + byChannel.sms + byChannel["in-app"];
491
+ return {
492
+ policy_slug: slug,
493
+ from: input.from,
494
+ to: input.to,
495
+ total: total,
496
+ by_channel: byChannel,
497
+ };
498
+ }
499
+
500
+ // ---- markAlertSent ----------------------------------------------------
501
+
502
+ async function markAlertSent(input) {
503
+ if (!input || typeof input !== "object") {
504
+ throw new TypeError("wishlistAlerts.markAlertSent: input object required");
505
+ }
506
+ var alertId = input.alert_id;
507
+ if (typeof alertId !== "string" || !alertId.length) {
508
+ throw new TypeError("wishlistAlerts.markAlertSent: alert_id must be a non-empty string");
509
+ }
510
+ var channel = _channel(input.channel);
511
+ var r = await query(
512
+ "UPDATE wishlist_alerts_sent SET channel = ?1 WHERE id = ?2",
513
+ [channel, alertId],
514
+ );
515
+ if (Number(r.rowCount || 0) === 0) {
516
+ throw new TypeError("wishlistAlerts.markAlertSent: alert_id " + JSON.stringify(alertId) + " not found");
517
+ }
518
+ var row = (await query(
519
+ "SELECT * FROM wishlist_alerts_sent WHERE id = ?1 LIMIT 1",
520
+ [alertId],
521
+ )).rows[0];
522
+ return {
523
+ id: row.id,
524
+ customer_id: row.customer_id,
525
+ policy_slug: row.policy_slug,
526
+ sku: row.sku,
527
+ channel: row.channel,
528
+ occurred_at: Number(row.occurred_at),
529
+ };
530
+ }
531
+
532
+ // ---- scanAndDispatch internals ----------------------------------------
533
+
534
+ async function _countAlertsThisWeek(customerId, now) {
535
+ var since = now - MS_PER_WEEK;
536
+ var r = await query(
537
+ "SELECT COUNT(*) AS n FROM wishlist_alerts_sent "
538
+ + "WHERE customer_id = ?1 AND occurred_at >= ?2",
539
+ [customerId, since],
540
+ );
541
+ return Number((r.rows[0] || {}).n || 0);
542
+ }
543
+
544
+ async function _hasRecentFire(customerId, sku, policySlug, now) {
545
+ var since = now - DEDUPE_WINDOW_MS;
546
+ var r = await query(
547
+ "SELECT id FROM wishlist_alerts_sent "
548
+ + "WHERE customer_id = ?1 AND sku = ?2 AND policy_slug = ?3 AND occurred_at >= ?4 "
549
+ + "LIMIT 1",
550
+ [customerId, sku, policySlug, since],
551
+ );
552
+ return r.rows.length > 0;
553
+ }
554
+
555
+ // Walk every wishlist entry that has a non-null variant_id. Whole-
556
+ // product (variant_id IS NULL) rows are skipped at v1 — the policy
557
+ // evaluator can't resolve a single sku for them, and the operator-
558
+ // facing way to expand a product wishlist into per-variant alerts is
559
+ // for the storefront to write per-variant rows.
560
+ async function _allWishlistEntries(batchSize) {
561
+ var r = await query(
562
+ "SELECT id, customer_id, product_id, variant_id, created_at "
563
+ + "FROM wishlist_entries WHERE variant_id IS NOT NULL "
564
+ + "ORDER BY created_at ASC, id ASC LIMIT ?1",
565
+ [batchSize],
566
+ );
567
+ return r.rows;
568
+ }
569
+
570
+ async function _resolveCurrency(customerId) {
571
+ if (currencyForCustomer) {
572
+ try {
573
+ var c = await currencyForCustomer(customerId);
574
+ if (typeof c === "string" && /^[A-Z]{3}$/.test(c)) return c;
575
+ } catch (_e) { /* drop-silent — fall through to default currency */ }
576
+ }
577
+ return defaultCurrency;
578
+ }
579
+
580
+ // Evaluate a single (entry, policy) pair. Returns either:
581
+ // { fire: true, sku, payload } — dispatch + ledger
582
+ // { fire: false, reason } — accounted as `skipped`
583
+ async function _evaluate(entry, policy, now) {
584
+ // Map variant → sku.
585
+ var variant;
586
+ try { variant = await catalog.variants.get(entry.variant_id); }
587
+ catch (_e) { variant = null; }
588
+ if (!variant) return { fire: false, reason: "variant_unresolvable" };
589
+ var sku = variant.sku;
590
+ if (typeof sku !== "string" || !sku.length) return { fire: false, reason: "variant_has_no_sku" };
591
+
592
+ if (policy.trigger === "price_drop") {
593
+ var currency = await _resolveCurrency(entry.customer_id);
594
+ var current;
595
+ try { current = await catalog.prices.current(entry.variant_id, currency); }
596
+ catch (_e) { current = null; }
597
+ if (!current) return { fire: false, reason: "no_current_price" };
598
+ var history;
599
+ try { history = await catalog.prices.history(entry.variant_id, currency); }
600
+ catch (_e) { history = []; }
601
+ // The current row is in history (effective_until IS NULL); the
602
+ // baseline is the most recent CLOSED row (effective_until set).
603
+ var baseline = null;
604
+ for (var i = 0; i < history.length; i += 1) {
605
+ var h = history[i];
606
+ if (h.effective_until != null) { baseline = h; break; }
607
+ }
608
+ if (!baseline) return { fire: false, reason: "no_baseline_price" };
609
+ var baseAmt = Number(baseline.amount_minor);
610
+ var curAmt = Number(current.amount_minor);
611
+ if (!(baseAmt > 0) || !(curAmt >= 0) || curAmt >= baseAmt) {
612
+ return { fire: false, reason: "no_drop" };
613
+ }
614
+ // Percent-off in basis points: ((base - cur) / base) * 10000.
615
+ var bps = Math.floor(((baseAmt - curAmt) * 10000) / baseAmt);
616
+ var threshold = Number(policy.threshold.percent_off_bps_min);
617
+ if (bps < threshold) return { fire: false, reason: "below_threshold" };
618
+ return {
619
+ fire: true,
620
+ sku: sku,
621
+ payload: {
622
+ variant_id: entry.variant_id,
623
+ currency: currency,
624
+ old_amount_minor: baseAmt,
625
+ new_amount_minor: curAmt,
626
+ discount_bps: bps,
627
+ },
628
+ };
629
+ }
630
+
631
+ if (policy.trigger === "back_in_stock") {
632
+ var inv;
633
+ try { inv = await catalog.inventory.get(sku); }
634
+ catch (_e) { inv = null; }
635
+ if (!inv) return { fire: false, reason: "inventory_unresolvable" };
636
+ var available = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
637
+ if (!(available > 0)) return { fire: false, reason: "still_out_of_stock" };
638
+ // Optional dep — when wired, suppress if the customer is already
639
+ // subscribed via the PDP "notify me" toggle. The dispatcher can
640
+ // only know the customer's email when we know they're a logged-
641
+ // in wishlist owner — and stockAlerts.isSubscribed is keyed by
642
+ // email, not customer_id, so we skip the cross-check unless the
643
+ // caller-supplied stockAlerts handle exposes a customer-keyed
644
+ // override. Keeping the cross-check shape forward-compatible:
645
+ // when stockAlerts is wired but doesn't expose `isSubscribedBy
646
+ // Customer`, the dispatcher fires anyway (no double-delivery
647
+ // suppression at v1, but the surface is in place).
648
+ if (stockAlerts && typeof stockAlerts.isSubscribedByCustomer === "function") {
649
+ var sub;
650
+ try { sub = await stockAlerts.isSubscribedByCustomer({ customer_id: entry.customer_id, sku: sku }); }
651
+ catch (_e) { sub = null; }
652
+ if (sub && sub.subscribed === true) return { fire: false, reason: "duplicate_via_stock_alerts" };
653
+ }
654
+ return {
655
+ fire: true,
656
+ sku: sku,
657
+ payload: {
658
+ variant_id: entry.variant_id,
659
+ available: available,
660
+ },
661
+ };
662
+ }
663
+
664
+ return { fire: false, reason: "policy_source_unwired" };
665
+ }
666
+
667
+ // Resolve a customer-facing email address. The wishlist primitive
668
+ // doesn't carry one — the operator's customer-store does. Callers
669
+ // that don't supply `opts.emailForCustomer` get a `no_email`
670
+ // skipped row. With `opts.emailForCustomer` wired, scanner asks
671
+ // for the customer's email per-fire (cache externally if hot).
672
+ var emailForCustomer = typeof opts.emailForCustomer === "function" ? opts.emailForCustomer : null;
673
+
674
+ async function scanAndDispatch(sweepOpts) {
675
+ sweepOpts = sweepOpts || {};
676
+ var now = _now(sweepOpts.now);
677
+ var batchSize = _batchSize(sweepOpts.batch_size);
678
+
679
+ var policies = await listAlertPolicies({ live_only: true, limit: MAX_LIST_LIMIT });
680
+ // Filter to scanner-wired triggers only; the others are accounted
681
+ // as `skipped` with `policy_source_unwired`.
682
+ var scannablePolicies = [];
683
+ var unwiredPolicyCount = 0;
684
+ for (var p = 0; p < policies.length; p += 1) {
685
+ if (SCANNABLE_TRIGGERS.indexOf(policies[p].trigger) === -1) {
686
+ unwiredPolicyCount += 1;
687
+ } else {
688
+ scannablePolicies.push(policies[p]);
689
+ }
690
+ }
691
+
692
+ var entries = await _allWishlistEntries(batchSize);
693
+ var scanned = entries.length;
694
+ var candidates = 0;
695
+ var sent = 0;
696
+ var skipped = 0;
697
+ var skippedBy = {
698
+ unsubscribed: 0,
699
+ weekly_cap_reached: 0,
700
+ recent_dedupe: 0,
701
+ no_email: 0,
702
+ email_dispatch_failed: 0,
703
+ no_baseline_price: 0,
704
+ no_current_price: 0,
705
+ no_drop: 0,
706
+ below_threshold: 0,
707
+ still_out_of_stock: 0,
708
+ inventory_unresolvable: 0,
709
+ variant_unresolvable: 0,
710
+ variant_has_no_sku: 0,
711
+ duplicate_via_stock_alerts: 0,
712
+ policy_source_unwired: unwiredPolicyCount,
713
+ };
714
+
715
+ for (var e = 0; e < entries.length; e += 1) {
716
+ var entry = entries[e];
717
+ // Per-customer-per-policy outer evaluation: walk every active
718
+ // policy this entry could match.
719
+ for (var pp = 0; pp < scannablePolicies.length; pp += 1) {
720
+ var policy = scannablePolicies[pp];
721
+
722
+ // Cheapest cuts first.
723
+ var optedOut = await isUnsubscribedFromTrigger(entry.customer_id, policy.trigger);
724
+ if (optedOut) { skipped += 1; skippedBy.unsubscribed += 1; continue; }
725
+
726
+ if (policy.max_alerts_per_week_per_customer != null) {
727
+ var weekN = await _countAlertsThisWeek(entry.customer_id, now);
728
+ if (weekN >= policy.max_alerts_per_week_per_customer) {
729
+ skipped += 1; skippedBy.weekly_cap_reached += 1; continue;
730
+ }
731
+ }
732
+
733
+ // Evaluate the policy condition.
734
+ var v = await _evaluate(entry, policy, now);
735
+ if (!v.fire) {
736
+ skipped += 1;
737
+ if (skippedBy[v.reason] != null) skippedBy[v.reason] += 1;
738
+ else skippedBy[v.reason] = 1;
739
+ continue;
740
+ }
741
+
742
+ // Per-(customer, sku, policy) recent-fire suppression.
743
+ var dup = await _hasRecentFire(entry.customer_id, v.sku, policy.slug, now);
744
+ if (dup) { skipped += 1; skippedBy.recent_dedupe += 1; continue; }
745
+
746
+ candidates += 1;
747
+
748
+ // Resolve the customer's email. Absent → no_email, skipped.
749
+ var customerEmail = null;
750
+ if (emailForCustomer) {
751
+ try { customerEmail = await emailForCustomer(entry.customer_id); }
752
+ catch (_e2) { customerEmail = null; }
753
+ }
754
+ if (typeof customerEmail !== "string" || !customerEmail.length) {
755
+ skipped += 1; skippedBy.no_email += 1; continue;
756
+ }
757
+
758
+ // Resolve a product title for the email. Best-effort — falls
759
+ // through to the sku string when the catalog read fails.
760
+ var productTitle = v.sku;
761
+ try {
762
+ if (catalog.products && typeof catalog.products.get === "function") {
763
+ var prod = await catalog.products.get(entry.product_id);
764
+ if (prod && typeof prod.title === "string" && prod.title.length) productTitle = prod.title;
765
+ }
766
+ } catch (_e3) { /* drop-silent — title is cosmetic */ }
767
+
768
+ // Dispatch via email.sendWishlistDiscount. For price_drop we
769
+ // pass the formatted price; for back_in_stock we pass "—" so
770
+ // the same template covers both v1 triggers without requiring
771
+ // a second email primitive method.
772
+ var dispatchOk = false;
773
+ try {
774
+ var oldPriceStr = v.payload.old_amount_minor != null
775
+ ? String(v.payload.old_amount_minor / 100) + " " + v.payload.currency
776
+ : "—";
777
+ var newPriceStr = v.payload.new_amount_minor != null
778
+ ? String(v.payload.new_amount_minor / 100) + " " + v.payload.currency
779
+ : "—";
780
+ var pctStr = v.payload.discount_bps != null
781
+ ? String(Math.floor(v.payload.discount_bps / 100)) + "%"
782
+ : "—";
783
+ await email.sendWishlistDiscount({
784
+ customer_email: customerEmail,
785
+ product_title: productTitle,
786
+ product_url: "/products/" + entry.product_id,
787
+ old_price: oldPriceStr,
788
+ new_price: newPriceStr,
789
+ discount_pct: pctStr,
790
+ });
791
+ dispatchOk = true;
792
+ } catch (_e4) {
793
+ // Drop-silent at the row level — a flapping mailer must not
794
+ // poison the rest of the scan. Account in skipped.
795
+ skipped += 1; skippedBy.email_dispatch_failed += 1; continue;
796
+ }
797
+ if (!dispatchOk) continue;
798
+
799
+ // Ledger write — the source of truth for "we already fired
800
+ // this alert" + the dedupe window.
801
+ var id = _b().uuid.v7();
802
+ var ts = _monotonicTs(now);
803
+ await query(
804
+ "INSERT INTO wishlist_alerts_sent (id, customer_id, policy_slug, sku, channel, occurred_at) "
805
+ + "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
806
+ [id, entry.customer_id, policy.slug, v.sku, "email", ts],
807
+ );
808
+ sent += 1;
809
+ }
810
+ }
811
+
812
+ return {
813
+ scanned: scanned,
814
+ candidates: candidates,
815
+ sent: sent,
816
+ skipped: skipped,
817
+ skipped_by: skippedBy,
818
+ };
819
+ }
820
+
821
+ return {
822
+ TRIGGERS: TRIGGERS,
823
+ CHANNELS: CHANNELS,
824
+
825
+ defineAlertPolicy: defineAlertPolicy,
826
+ getAlertPolicy: getAlertPolicy,
827
+ listAlertPolicies: listAlertPolicies,
828
+ archiveAlertPolicy: archiveAlertPolicy,
829
+
830
+ scanAndDispatch: scanAndDispatch,
831
+ markAlertSent: markAlertSent,
832
+ customerAlertHistory: customerAlertHistory,
833
+ unsubscribeFromAlertKind: unsubscribeFromAlertKind,
834
+ metricsForPolicy: metricsForPolicy,
835
+ };
836
+ }
837
+
838
+ module.exports = {
839
+ create: create,
840
+ TRIGGERS: TRIGGERS,
841
+ CHANNELS: CHANNELS,
842
+ };