@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,798 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.reorderReminders
4
+ * @title Reorder reminders — customer-facing "time to reorder" nudges
5
+ *
6
+ * @intro
7
+ * Distinct from `reorderThresholds` (the sibling primitive that
8
+ * answers "the operator's warehouse is low — propose a PO to the
9
+ * supplier"). This primitive answers a different question: "the
10
+ * customer's pantry is low — nudge them to reorder before they
11
+ * run out". Consumable categories (water filters, contact lenses,
12
+ * pet food, vitamins, coffee beans) have a predictable run-out
13
+ * cadence; missing the reorder window costs the operator a
14
+ * recurring-revenue line.
15
+ *
16
+ * The shape:
17
+ *
18
+ * var rr = b.shop.reorderReminders.create({
19
+ * query: q,
20
+ * order: orderPrimitive, // optional read-only dep
21
+ * notifications: notif, // wired for in_app dispatch
22
+ * email: email, // wired for email dispatch
23
+ * });
24
+ *
25
+ * // Operator defines the cadence for each consumable SKU.
26
+ * await rr.defineReminderProfile({
27
+ * sku: "FILTER-PUR-12M",
28
+ * interval_days: 365,
29
+ * message_template_slug: "reorder-filter-12m",
30
+ * channel: "email",
31
+ * });
32
+ *
33
+ * // The order-completion hook enrols the customer with the
34
+ * // recorded purchase timestamp; absent that timestamp, the
35
+ * // enrolment falls back to "now" and the first reminder fires
36
+ * // `interval_days` from today.
37
+ * await rr.enrollCustomerSku({
38
+ * customer_id: customerId,
39
+ * sku: "FILTER-PUR-12M",
40
+ * last_order_at: orderRow.placed_at,
41
+ * });
42
+ *
43
+ * // Scheduler tick — operator wires this to a cron / Workers
44
+ * // Cron Trigger. Pulls every active enrollment whose
45
+ * // next_remind_at is due, dispatches via the profile's channel,
46
+ * // and stamps a `reorder_dispatches` row per send.
47
+ * await rr.dispatchTick({ now: Date.now() });
48
+ *
49
+ * Verbs:
50
+ * defineReminderProfile — register / replace the SKU's cadence.
51
+ * Per-SKU uniqueness; redefining the active row patches it in
52
+ * place rather than archiving (the SKU's cadence is global,
53
+ * not versioned per customer).
54
+ *
55
+ * enrollCustomerSku — enrol a (customer_id, sku) pair on the
56
+ * given last_order_at. `next_remind_at` is computed from the
57
+ * profile's interval_days. Re-enrolling the same pair refreshes
58
+ * last_order_at and re-arms the next reminder — the operator
59
+ * calls this from every order-completion hook so the nudge
60
+ * window always tracks the freshest purchase.
61
+ *
62
+ * dispatchTick — pull every active enrollment whose
63
+ * next_remind_at <= now, walk each via the profile's channel,
64
+ * stamp a dispatches row, and advance next_remind_at by one
65
+ * interval. Returns the dispatched rows so the caller can log.
66
+ *
67
+ * recordSent / markFailed — terminal markers for the channel
68
+ * hooks. `dispatchTick` calls these inline when the channel
69
+ * composition returns; an operator with an async provider
70
+ * (deferred email gateway, SMS DLR callback) can also call
71
+ * them out-of-band against an `enrollment_id` they captured.
72
+ *
73
+ * remindersForCustomer — customer-portal read of an account's
74
+ * enrollments. Filter by status; cursor paginates oldest-first.
75
+ *
76
+ * unsubscribeFromSku — flip an enrollment to `cancelled` with
77
+ * the unsubscribed_at stamp. Idempotent. Future dispatchTick
78
+ * runs skip the row.
79
+ *
80
+ * metricsForProfile — count sent / failed dispatches in a
81
+ * window for one SKU. Powers the operator's "is this nudge
82
+ * converting" dashboard.
83
+ *
84
+ * Composition:
85
+ * - b.uuid.v7 — enrollment + dispatch row ids
86
+ * - b.guardUuid — customer_id sanitization at every entry point
87
+ * - notifications (optional) — `in_app` channel composes
88
+ * `notifications.enqueue`
89
+ * - email (optional) — `email` channel composes `email.send`
90
+ * (or any wired-shape `send({ to, template, ... })` hook)
91
+ * - sms (optional) — `sms` channel composes `sms.send`
92
+ *
93
+ * Three-tier input validation: every public verb is a config-time
94
+ * entry point (defineReminderProfile) or a defensive request-
95
+ * shape reader (everything else). All shapes throw on bad input;
96
+ * no drop-silent hot paths.
97
+ */
98
+
99
+ var bShop;
100
+ function _b() {
101
+ if (!bShop) bShop = require("./index");
102
+ return bShop.framework;
103
+ }
104
+
105
+ // ---- constants ----------------------------------------------------------
106
+
107
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
108
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
109
+ var ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
110
+ var DAY_MS = 24 * 60 * 60 * 1000;
111
+ var MAX_INTERVAL_DAYS = 3650; // 10 years — same envelope as supplier lead times; refuses Number.MAX_SAFE_INTEGER typos
112
+ var MAX_BATCH_SIZE = 500;
113
+ var DEFAULT_BATCH_SIZE = 100;
114
+ var MAX_LIMIT = 500;
115
+ var DEFAULT_LIMIT = 50;
116
+ var MAX_REASON_LEN = 1024;
117
+ var CHANNELS = Object.freeze(["email", "sms", "in_app"]);
118
+ var STATUSES = Object.freeze(["active", "paused", "cancelled"]);
119
+ var DISPATCH_STATUSES = Object.freeze(["sent", "failed"]);
120
+
121
+ // ---- monotonic clock ----------------------------------------------------
122
+ //
123
+ // Two enrollments / dispatches written inside the same millisecond would
124
+ // otherwise collide on the v7-uuid timestamp prefix and tie on
125
+ // (next_remind_at, id) ordering. The monotonic step guarantees strict-
126
+ // increase so the dispatcher's batch read returns a deterministic order
127
+ // without depending on the v7 sub-ms counter.
128
+ var _lastTs = 0;
129
+ function _now() {
130
+ var t = Date.now();
131
+ if (t <= _lastTs) { t = _lastTs + 1; }
132
+ _lastTs = t;
133
+ return t;
134
+ }
135
+
136
+ // ---- validators ---------------------------------------------------------
137
+
138
+ function _sku(s) {
139
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
140
+ throw new TypeError("reorder-reminders: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
141
+ }
142
+ return s;
143
+ }
144
+
145
+ function _templateSlug(s) {
146
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
147
+ throw new TypeError("reorder-reminders: message_template_slug must match /^[a-z0-9][a-z0-9._-]*$/ (lowercase alnum + . _ -, 1..128 chars)");
148
+ }
149
+ return s;
150
+ }
151
+
152
+ function _channel(s) {
153
+ if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
154
+ throw new TypeError("reorder-reminders: channel must be one of " + CHANNELS.join(", "));
155
+ }
156
+ return s;
157
+ }
158
+
159
+ function _intervalDays(n) {
160
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_INTERVAL_DAYS) {
161
+ throw new TypeError("reorder-reminders: interval_days must be a positive integer ≤ " + MAX_INTERVAL_DAYS);
162
+ }
163
+ return n;
164
+ }
165
+
166
+ function _customerId(s) {
167
+ try {
168
+ return _b().guardUuid.sanitize(s, { profile: "strict" });
169
+ } catch (e) {
170
+ throw new TypeError("reorder-reminders: customer_id — " + (e && e.message || "invalid UUID"));
171
+ }
172
+ }
173
+
174
+ function _id(s, label) {
175
+ if (typeof s !== "string" || !ID_RE.test(s)) {
176
+ throw new TypeError("reorder-reminders: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
177
+ }
178
+ return s;
179
+ }
180
+
181
+ function _epochMs(n, label) {
182
+ if (!Number.isInteger(n) || n < 0) {
183
+ throw new TypeError("reorder-reminders: " + label + " must be a non-negative integer (epoch ms)");
184
+ }
185
+ return n;
186
+ }
187
+
188
+ function _optionalEpochMs(n, label) {
189
+ if (n == null) return null;
190
+ return _epochMs(n, label);
191
+ }
192
+
193
+ function _status(s) {
194
+ if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
195
+ throw new TypeError("reorder-reminders: status must be one of " + STATUSES.join(", "));
196
+ }
197
+ return s;
198
+ }
199
+
200
+ function _batchSize(n) {
201
+ if (n == null) return DEFAULT_BATCH_SIZE;
202
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
203
+ throw new TypeError("reorder-reminders: batch_size must be an integer in 1.." + MAX_BATCH_SIZE);
204
+ }
205
+ return n;
206
+ }
207
+
208
+ function _limit(n) {
209
+ if (n == null) return DEFAULT_LIMIT;
210
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
211
+ throw new TypeError("reorder-reminders: limit must be an integer in 1.." + MAX_LIMIT);
212
+ }
213
+ return n;
214
+ }
215
+
216
+ function _failReason(s) {
217
+ if (typeof s !== "string" || !s.length) {
218
+ throw new TypeError("reorder-reminders: reason must be a non-empty string");
219
+ }
220
+ if (s.length > MAX_REASON_LEN) {
221
+ throw new TypeError("reorder-reminders: reason must be ≤ " + MAX_REASON_LEN + " characters");
222
+ }
223
+ return s;
224
+ }
225
+
226
+ // ---- factory ------------------------------------------------------------
227
+
228
+ function create(opts) {
229
+ opts = opts || {};
230
+ // `order` is held as an optional read-only marker — operators that
231
+ // want the primitive to backfill last_order_at from a recent order
232
+ // can compose that at the caller. The primitive never reads from
233
+ // this dep directly today; the factory accepts it so the wiring is
234
+ // explicit at construction time and a future verb can consume it
235
+ // without changing the public factory shape.
236
+ var order = opts.order || null;
237
+ if (order !== null && typeof order !== "object") {
238
+ throw new TypeError("reorder-reminders.create: opts.order must be an object or null");
239
+ }
240
+ var notifications = opts.notifications || null;
241
+ if (notifications !== null && typeof notifications !== "object") {
242
+ throw new TypeError("reorder-reminders.create: opts.notifications must be an object or null");
243
+ }
244
+ var email = opts.email || null;
245
+ if (email !== null && typeof email !== "object") {
246
+ throw new TypeError("reorder-reminders.create: opts.email must be an object or null");
247
+ }
248
+ var sms = opts.sms || null;
249
+ if (sms !== null && typeof sms !== "object") {
250
+ throw new TypeError("reorder-reminders.create: opts.sms must be an object or null");
251
+ }
252
+ var query = opts.query;
253
+ if (!query) {
254
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
255
+ }
256
+
257
+ // ---- internal shapers ------------------------------------------------
258
+
259
+ function _shapeProfile(row) {
260
+ if (!row) return null;
261
+ return {
262
+ sku: row.sku,
263
+ interval_days: row.interval_days,
264
+ message_template_slug: row.message_template_slug,
265
+ channel: row.channel,
266
+ archived_at: row.archived_at,
267
+ created_at: row.created_at,
268
+ updated_at: row.updated_at,
269
+ };
270
+ }
271
+
272
+ function _shapeEnrollment(row) {
273
+ if (!row) return null;
274
+ return {
275
+ id: row.id,
276
+ customer_id: row.customer_id,
277
+ sku: row.sku,
278
+ last_order_at: row.last_order_at,
279
+ next_remind_at: row.next_remind_at,
280
+ status: row.status,
281
+ unsubscribed_at: row.unsubscribed_at,
282
+ created_at: row.created_at,
283
+ };
284
+ }
285
+
286
+ function _shapeDispatch(row) {
287
+ if (!row) return null;
288
+ return {
289
+ id: row.id,
290
+ enrollment_id: row.enrollment_id,
291
+ status: row.status,
292
+ occurred_at: row.occurred_at,
293
+ fail_reason: row.fail_reason,
294
+ };
295
+ }
296
+
297
+ async function _getActiveProfile(sku) {
298
+ var r = await query(
299
+ "SELECT * FROM reorder_profiles WHERE sku = ?1 AND archived_at IS NULL LIMIT 1",
300
+ [sku],
301
+ );
302
+ return r.rows[0] || null;
303
+ }
304
+
305
+ async function _getProfileBySku(sku) {
306
+ var r = await query(
307
+ "SELECT * FROM reorder_profiles WHERE sku = ?1 LIMIT 1",
308
+ [sku],
309
+ );
310
+ return r.rows[0] || null;
311
+ }
312
+
313
+ async function _getEnrollmentById(id) {
314
+ var r = await query(
315
+ "SELECT * FROM reorder_enrollments WHERE id = ?1 LIMIT 1",
316
+ [id],
317
+ );
318
+ return r.rows[0] || null;
319
+ }
320
+
321
+ async function _getEnrollmentByPair(customerId, sku) {
322
+ var r = await query(
323
+ "SELECT * FROM reorder_enrollments WHERE customer_id = ?1 AND sku = ?2 LIMIT 1",
324
+ [customerId, sku],
325
+ );
326
+ return r.rows[0] || null;
327
+ }
328
+
329
+ // Dispatch one due enrollment through the profile's channel. Returns
330
+ // `{ ok: true }` on success, `{ ok: false, reason }` on failure. The
331
+ // channel deps are optional at factory time so the primitive can be
332
+ // exercised in isolation; a missing dep for a configured channel is
333
+ // a typed failure rather than a silent drop — the operator either
334
+ // wires the dep or archives the profile.
335
+ async function _dispatchOne(enrollment, profile) {
336
+ if (profile.channel === "in_app") {
337
+ if (!notifications || typeof notifications.enqueue !== "function") {
338
+ return { ok: false, reason: "notifications-dep-missing" };
339
+ }
340
+ try {
341
+ await notifications.enqueue({
342
+ recipient_id: enrollment.customer_id,
343
+ channel: "in-app",
344
+ event_type: "reorder.reminder",
345
+ title: "Time to reorder " + enrollment.sku,
346
+ body: "Reorder reminder for " + enrollment.sku,
347
+ payload: {
348
+ sku: enrollment.sku,
349
+ template_slug: profile.message_template_slug,
350
+ enrollment_id: enrollment.id,
351
+ },
352
+ });
353
+ return { ok: true };
354
+ } catch (e) {
355
+ return { ok: false, reason: "notifications-enqueue-failed: " + (e && e.message || "unknown") };
356
+ }
357
+ }
358
+ if (profile.channel === "email") {
359
+ if (!email || typeof email.send !== "function") {
360
+ return { ok: false, reason: "email-dep-missing" };
361
+ }
362
+ try {
363
+ await email.send({
364
+ to: enrollment.customer_id,
365
+ template: profile.message_template_slug,
366
+ sku: enrollment.sku,
367
+ enrollment_id: enrollment.id,
368
+ });
369
+ return { ok: true };
370
+ } catch (e) {
371
+ return { ok: false, reason: "email-send-failed: " + (e && e.message || "unknown") };
372
+ }
373
+ }
374
+ if (profile.channel === "sms") {
375
+ if (!sms || typeof sms.send !== "function") {
376
+ return { ok: false, reason: "sms-dep-missing" };
377
+ }
378
+ try {
379
+ await sms.send({
380
+ customer_id: enrollment.customer_id,
381
+ template: profile.message_template_slug,
382
+ sku: enrollment.sku,
383
+ enrollment_id: enrollment.id,
384
+ });
385
+ return { ok: true };
386
+ } catch (e) {
387
+ return { ok: false, reason: "sms-send-failed: " + (e && e.message || "unknown") };
388
+ }
389
+ }
390
+ // Defensive: the CHECK constraint refuses any other value at the
391
+ // SQL boundary, but the branch is reached only via a corrupted
392
+ // row — surface a typed reason rather than a silent skip.
393
+ return { ok: false, reason: "unknown-channel: " + profile.channel };
394
+ }
395
+
396
+ return {
397
+
398
+ // Constants surfaced for tests + admin dashboards.
399
+ CHANNELS: CHANNELS,
400
+ STATUSES: STATUSES,
401
+ DISPATCH_STATUSES: DISPATCH_STATUSES,
402
+
403
+ // Register / replace the SKU's reorder cadence. The SKU is the PK
404
+ // — a profile's cadence is global to the catalog. Redefining the
405
+ // same SKU patches the existing row in place (interval_days /
406
+ // message_template_slug / channel) rather than archiving; the
407
+ // archived path is reserved for "stop nudging this SKU at all".
408
+ defineReminderProfile: async function (input) {
409
+ if (!input || typeof input !== "object") {
410
+ throw new TypeError("reorder-reminders.defineReminderProfile: input object required");
411
+ }
412
+ var sku = _sku(input.sku);
413
+ var intervalDays = _intervalDays(input.interval_days);
414
+ var messageTemplateSlug = _templateSlug(input.message_template_slug);
415
+ var channel = _channel(input.channel);
416
+
417
+ var existing = await _getProfileBySku(sku);
418
+ var ts = _now();
419
+ if (existing) {
420
+ // Patch in place — clearing archived_at if the operator is
421
+ // re-activating an archived profile.
422
+ await query(
423
+ "UPDATE reorder_profiles SET interval_days = ?1, message_template_slug = ?2, " +
424
+ "channel = ?3, archived_at = NULL, updated_at = ?4 WHERE sku = ?5",
425
+ [intervalDays, messageTemplateSlug, channel, ts, sku],
426
+ );
427
+ return _shapeProfile(await _getProfileBySku(sku));
428
+ }
429
+ await query(
430
+ "INSERT INTO reorder_profiles (sku, interval_days, message_template_slug, channel, " +
431
+ "archived_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
432
+ [sku, intervalDays, messageTemplateSlug, channel, ts],
433
+ );
434
+ return _shapeProfile(await _getProfileBySku(sku));
435
+ },
436
+
437
+ // Enrol a (customer_id, sku) pair. The profile MUST be active —
438
+ // an operator that archives the profile has paused the campaign
439
+ // and shouldn't be silently enrolling new customers. Re-enrolling
440
+ // the same pair refreshes last_order_at and re-arms next_remind_at
441
+ // (the order-completion hook is the canonical caller, and every
442
+ // fresh order should reset the cadence).
443
+ enrollCustomerSku: async function (input) {
444
+ if (!input || typeof input !== "object") {
445
+ throw new TypeError("reorder-reminders.enrollCustomerSku: input object required");
446
+ }
447
+ var customerId = _customerId(input.customer_id);
448
+ var sku = _sku(input.sku);
449
+ var lastOrderAt = input.last_order_at == null ? _now() : _epochMs(input.last_order_at, "last_order_at");
450
+
451
+ var profile = await _getActiveProfile(sku);
452
+ if (!profile) {
453
+ throw new TypeError("reorder-reminders.enrollCustomerSku: no active reminder profile for sku " +
454
+ JSON.stringify(sku) + " — call defineReminderProfile first");
455
+ }
456
+ var nextRemindAt = lastOrderAt + profile.interval_days * DAY_MS;
457
+ var ts = _now();
458
+ var existing = await _getEnrollmentByPair(customerId, sku);
459
+ if (existing) {
460
+ // Re-enrolment refreshes the cadence + clears any prior
461
+ // unsubscribe / paused state. Operators that want sticky opt-
462
+ // outs hold the customer's unsubscribe at a higher layer; this
463
+ // primitive treats a fresh order as an implicit re-opt-in,
464
+ // which matches the operator's intent for the hook ("they
465
+ // just bought it again — keep nudging them").
466
+ await query(
467
+ "UPDATE reorder_enrollments SET last_order_at = ?1, next_remind_at = ?2, " +
468
+ "status = 'active', unsubscribed_at = NULL WHERE id = ?3",
469
+ [lastOrderAt, nextRemindAt, existing.id],
470
+ );
471
+ return _shapeEnrollment(await _getEnrollmentById(existing.id));
472
+ }
473
+ var id = _b().uuid.v7({ now: ts });
474
+ await query(
475
+ "INSERT INTO reorder_enrollments (id, customer_id, sku, last_order_at, next_remind_at, " +
476
+ "status, unsubscribed_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'active', NULL, ?6)",
477
+ [id, customerId, sku, lastOrderAt, nextRemindAt, ts],
478
+ );
479
+ return _shapeEnrollment(await _getEnrollmentById(id));
480
+ },
481
+
482
+ // Scheduler tick: pull every active enrollment whose
483
+ // next_remind_at is due, dispatch via the profile's channel,
484
+ // stamp a dispatches row per attempt, and advance next_remind_at
485
+ // by one interval (on success only — failed rows stay at the
486
+ // current next_remind_at so the retry sweep picks them up on the
487
+ // next tick). Returns the dispatched rows so the caller can log.
488
+ dispatchTick: async function (input) {
489
+ input = input || {};
490
+ var now = input.now == null ? _now() : _epochMs(input.now, "now");
491
+ var batchSize = _batchSize(input.batch_size);
492
+
493
+ var due = await query(
494
+ "SELECT * FROM reorder_enrollments " +
495
+ "WHERE status = 'active' AND next_remind_at <= ?1 " +
496
+ "ORDER BY next_remind_at ASC, id ASC LIMIT ?2",
497
+ [now, batchSize],
498
+ );
499
+
500
+ var dispatched = [];
501
+ for (var i = 0; i < due.rows.length; i += 1) {
502
+ var enrollment = due.rows[i];
503
+ var profile = await _getActiveProfile(enrollment.sku);
504
+ var dispatchId = _b().uuid.v7({ now: _now() });
505
+ if (!profile) {
506
+ // Profile archived after enrollment — terminal-fail the
507
+ // attempt so the operator's metrics surface the
508
+ // misconfiguration. The enrollment stays active so the
509
+ // operator can re-define the profile and the next tick
510
+ // sweeps it.
511
+ await query(
512
+ "INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
513
+ "VALUES (?1, ?2, 'failed', ?3, ?4)",
514
+ [dispatchId, enrollment.id, _now(), "profile-archived-or-missing"],
515
+ );
516
+ dispatched.push(_shapeDispatch({
517
+ id: dispatchId,
518
+ enrollment_id: enrollment.id,
519
+ status: "failed",
520
+ occurred_at: now,
521
+ fail_reason: "profile-archived-or-missing",
522
+ }));
523
+ continue;
524
+ }
525
+
526
+ var result = await _dispatchOne(enrollment, profile);
527
+ var occurredAt = _now();
528
+ if (result.ok) {
529
+ await query(
530
+ "INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
531
+ "VALUES (?1, ?2, 'sent', ?3, NULL)",
532
+ [dispatchId, enrollment.id, occurredAt],
533
+ );
534
+ // Advance next_remind_at by one interval from the current
535
+ // next_remind_at (not from `now`) so a late dispatcher tick
536
+ // doesn't drift the cadence forward.
537
+ var nextRemindAt = enrollment.next_remind_at + profile.interval_days * DAY_MS;
538
+ await query(
539
+ "UPDATE reorder_enrollments SET next_remind_at = ?1 WHERE id = ?2",
540
+ [nextRemindAt, enrollment.id],
541
+ );
542
+ dispatched.push(_shapeDispatch({
543
+ id: dispatchId,
544
+ enrollment_id: enrollment.id,
545
+ status: "sent",
546
+ occurred_at: occurredAt,
547
+ fail_reason: null,
548
+ }));
549
+ } else {
550
+ await query(
551
+ "INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
552
+ "VALUES (?1, ?2, 'failed', ?3, ?4)",
553
+ [dispatchId, enrollment.id, occurredAt, result.reason],
554
+ );
555
+ dispatched.push(_shapeDispatch({
556
+ id: dispatchId,
557
+ enrollment_id: enrollment.id,
558
+ status: "failed",
559
+ occurred_at: occurredAt,
560
+ fail_reason: result.reason,
561
+ }));
562
+ }
563
+ }
564
+ return dispatched;
565
+ },
566
+
567
+ // Terminal marker for async channel hooks. The synchronous path
568
+ // through dispatchTick stamps these inline, but an async provider
569
+ // (deferred email gateway, SMS DLR callback) writes through here.
570
+ // The enrollment_id is the only key; reminder_id is the dispatch
571
+ // row id and is generated here, so the operator's hook only needs
572
+ // the enrollment.
573
+ recordSent: async function (input) {
574
+ if (!input || typeof input !== "object") {
575
+ throw new TypeError("reorder-reminders.recordSent: input object required");
576
+ }
577
+ var enrollmentId = _id(input.reminder_id || input.enrollment_id, "reminder_id");
578
+ var sentAt = input.sent_at == null ? _now() : _epochMs(input.sent_at, "sent_at");
579
+ var enrollment = await _getEnrollmentById(enrollmentId);
580
+ if (!enrollment) {
581
+ throw new TypeError("reorder-reminders.recordSent: enrollment_id " +
582
+ JSON.stringify(enrollmentId) + " not found");
583
+ }
584
+ var dispatchId = _b().uuid.v7({ now: _now() });
585
+ await query(
586
+ "INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
587
+ "VALUES (?1, ?2, 'sent', ?3, NULL)",
588
+ [dispatchId, enrollmentId, sentAt],
589
+ );
590
+ // Advance the cadence here too — recordSent is the terminal
591
+ // marker for a successful send regardless of which path
592
+ // (sync via dispatchTick / async via provider callback)
593
+ // produced the row.
594
+ var profile = await _getActiveProfile(enrollment.sku);
595
+ if (profile) {
596
+ var nextRemindAt = enrollment.next_remind_at + profile.interval_days * DAY_MS;
597
+ await query(
598
+ "UPDATE reorder_enrollments SET next_remind_at = ?1 WHERE id = ?2",
599
+ [nextRemindAt, enrollmentId],
600
+ );
601
+ }
602
+ var r = await query(
603
+ "SELECT * FROM reorder_dispatches WHERE id = ?1 LIMIT 1",
604
+ [dispatchId],
605
+ );
606
+ return _shapeDispatch(r.rows[0]);
607
+ },
608
+
609
+ markFailed: async function (input) {
610
+ if (!input || typeof input !== "object") {
611
+ throw new TypeError("reorder-reminders.markFailed: input object required");
612
+ }
613
+ var enrollmentId = _id(input.reminder_id || input.enrollment_id, "reminder_id");
614
+ var reason = _failReason(input.reason);
615
+ var enrollment = await _getEnrollmentById(enrollmentId);
616
+ if (!enrollment) {
617
+ throw new TypeError("reorder-reminders.markFailed: enrollment_id " +
618
+ JSON.stringify(enrollmentId) + " not found");
619
+ }
620
+ var occurredAt = _now();
621
+ var dispatchId = _b().uuid.v7({ now: occurredAt });
622
+ await query(
623
+ "INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
624
+ "VALUES (?1, ?2, 'failed', ?3, ?4)",
625
+ [dispatchId, enrollmentId, occurredAt, reason],
626
+ );
627
+ var r = await query(
628
+ "SELECT * FROM reorder_dispatches WHERE id = ?1 LIMIT 1",
629
+ [dispatchId],
630
+ );
631
+ return _shapeDispatch(r.rows[0]);
632
+ },
633
+
634
+ // Customer-portal read. Defaults to all statuses; cursor is the
635
+ // created_at of the last row returned (oldest-first the customer
636
+ // sees their longest-running subscriptions). Optional `status`
637
+ // filter narrows to active / paused / cancelled.
638
+ remindersForCustomer: async function (input) {
639
+ if (!input || typeof input !== "object") {
640
+ throw new TypeError("reorder-reminders.remindersForCustomer: input object required");
641
+ }
642
+ var customerId = _customerId(input.customer_id);
643
+ var limit = _limit(input.limit);
644
+ var cursor = _optionalEpochMs(input.cursor, "cursor");
645
+ var status = input.status == null ? null : _status(input.status);
646
+
647
+ var clauses = ["customer_id = ?1"];
648
+ var params = [customerId];
649
+ var idx = 2;
650
+ if (status) {
651
+ clauses.push("status = ?" + idx); params.push(status); idx += 1;
652
+ }
653
+ if (cursor != null) {
654
+ clauses.push("created_at > ?" + idx); params.push(cursor); idx += 1;
655
+ }
656
+ params.push(limit);
657
+ var sql = "SELECT * FROM reorder_enrollments WHERE " + clauses.join(" AND ") +
658
+ " ORDER BY created_at ASC, id ASC LIMIT ?" + idx;
659
+ var r = await query(sql, params);
660
+ var rows = r.rows.map(_shapeEnrollment);
661
+ var nextCursor = null;
662
+ if (rows.length === limit) {
663
+ nextCursor = Number(rows[rows.length - 1].created_at);
664
+ }
665
+ return { rows: rows, next_cursor: nextCursor };
666
+ },
667
+
668
+ // Flip an enrollment to `cancelled` with the unsubscribed_at
669
+ // stamp. Idempotent — re-cancelling preserves the original
670
+ // unsubscribed_at. Future dispatchTick runs skip the row because
671
+ // the partial index targets `status = 'active'`.
672
+ unsubscribeFromSku: async function (input) {
673
+ if (!input || typeof input !== "object") {
674
+ throw new TypeError("reorder-reminders.unsubscribeFromSku: input object required");
675
+ }
676
+ var customerId = _customerId(input.customer_id);
677
+ var sku = _sku(input.sku);
678
+ var existing = await _getEnrollmentByPair(customerId, sku);
679
+ if (!existing) {
680
+ throw new TypeError("reorder-reminders.unsubscribeFromSku: no enrollment for customer " +
681
+ JSON.stringify(customerId) + " sku " + JSON.stringify(sku));
682
+ }
683
+ if (existing.status === "cancelled") {
684
+ return _shapeEnrollment(existing);
685
+ }
686
+ var ts = _now();
687
+ await query(
688
+ "UPDATE reorder_enrollments SET status = 'cancelled', unsubscribed_at = ?1 WHERE id = ?2",
689
+ [ts, existing.id],
690
+ );
691
+ return _shapeEnrollment(await _getEnrollmentById(existing.id));
692
+ },
693
+
694
+ // Window-scoped metrics. Counts sent / failed dispatches for one
695
+ // SKU between `from` and `to`. The operator's dashboard powers
696
+ // "what's the success rate for this nudge campaign" off this.
697
+ metricsForProfile: async function (input) {
698
+ if (!input || typeof input !== "object") {
699
+ throw new TypeError("reorder-reminders.metricsForProfile: input object required");
700
+ }
701
+ var sku = _sku(input.sku);
702
+ var from = _epochMs(input.from, "from");
703
+ var to = _epochMs(input.to, "to");
704
+ if (to < from) {
705
+ throw new TypeError("reorder-reminders.metricsForProfile: to (" + to +
706
+ ") must be ≥ from (" + from + ")");
707
+ }
708
+ var r = await query(
709
+ "SELECT d.status AS s, COUNT(*) AS c FROM reorder_dispatches d " +
710
+ "JOIN reorder_enrollments e ON e.id = d.enrollment_id " +
711
+ "WHERE e.sku = ?1 AND d.occurred_at >= ?2 AND d.occurred_at <= ?3 " +
712
+ "GROUP BY d.status",
713
+ [sku, from, to],
714
+ );
715
+ var sent = 0;
716
+ var failed = 0;
717
+ for (var i = 0; i < r.rows.length; i += 1) {
718
+ var row = r.rows[i];
719
+ if (row.s === "sent") sent = Number(row.c) || 0;
720
+ else if (row.s === "failed") failed = Number(row.c) || 0;
721
+ }
722
+ return {
723
+ sku: sku,
724
+ from: from,
725
+ to: to,
726
+ sent_count: sent,
727
+ failed_count: failed,
728
+ total_count: sent + failed,
729
+ };
730
+ },
731
+ };
732
+ }
733
+
734
+ // Top-level `run()` for direct invocation — exercises the primitive's
735
+ // factory shape against an in-memory query stub so the smoke caller
736
+ // (operator's release pipeline) can confirm the module loads + the
737
+ // factory composes without touching a remote D1 or a migration file.
738
+ // The stub is a minimal in-memory map indexed by SQL verb; it covers
739
+ // enough of the surface to round-trip a defineReminderProfile call.
740
+ async function run() {
741
+ var profiles = {};
742
+ var enrolments = [];
743
+ var dispatches = [];
744
+ var q = async function (sql, params) {
745
+ params = params || [];
746
+ var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
747
+ if (verb === "SELECT" && /FROM reorder_profiles/.test(sql)) {
748
+ var sku = params[0];
749
+ var p = profiles[sku];
750
+ return { rows: p ? [p] : [], rowCount: p ? 1 : 0 };
751
+ }
752
+ if (verb === "INSERT" && /reorder_profiles/.test(sql)) {
753
+ profiles[params[0]] = {
754
+ sku: params[0],
755
+ interval_days: params[1],
756
+ message_template_slug: params[2],
757
+ channel: params[3],
758
+ archived_at: null,
759
+ created_at: params[4],
760
+ updated_at: params[4],
761
+ };
762
+ return { rows: [], rowCount: 1 };
763
+ }
764
+ if (verb === "UPDATE" && /reorder_profiles/.test(sql)) {
765
+ var existing = profiles[params[4]];
766
+ if (existing) {
767
+ existing.interval_days = params[0];
768
+ existing.message_template_slug = params[1];
769
+ existing.channel = params[2];
770
+ existing.archived_at = null;
771
+ existing.updated_at = params[3];
772
+ }
773
+ return { rows: [], rowCount: existing ? 1 : 0 };
774
+ }
775
+ // Untouched paths return an empty result — the smoke caller
776
+ // only exercises defineReminderProfile.
777
+ return { rows: [], rowCount: 0 };
778
+ };
779
+ // Keep the unused locals referenced so a future smoke extension can
780
+ // walk the full surface without a dangling-variable lint trip.
781
+ void enrolments; void dispatches;
782
+ var rr = create({ query: q });
783
+ await rr.defineReminderProfile({
784
+ sku: "FILTER-PUR-12M",
785
+ interval_days: 365,
786
+ message_template_slug: "reorder-filter-12m",
787
+ channel: "in_app",
788
+ });
789
+ return { ok: true };
790
+ }
791
+
792
+ module.exports = {
793
+ create: create,
794
+ run: run,
795
+ CHANNELS: CHANNELS,
796
+ STATUSES: STATUSES,
797
+ DISPATCH_STATUSES: DISPATCH_STATUSES,
798
+ };