@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.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,1350 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.winbackCampaigns
4
+ * @title Winback campaigns — re-engagement sequences for lapsed customers
5
+ *
6
+ * @intro
7
+ * `cartRecovery` nurtures a single abandoned cart; this primitive
8
+ * nurtures a customer who's gone quiet across every cart — the
9
+ * last paid order is N days in the past and the operator wants to
10
+ * land a sequence of escalating offers ("we miss you" → "10% off"
11
+ * → "last call, 20% off") to pull them back.
12
+ *
13
+ * The shape:
14
+ *
15
+ * var wb = bShop.winbackCampaigns.create({
16
+ * query: q,
17
+ * order: bShop.order.create({ ... }), // optional
18
+ * customerSegments: bShop.customerSegments.create({ ... }), // optional
19
+ * email: bShop.email.create({ mailer: m }), // optional
20
+ * emailSuppressions: bShop.emailSuppressions.create({ query: q }), // optional
21
+ * coupons: couponMinter, // optional
22
+ * });
23
+ *
24
+ * await wb.defineCampaign({
25
+ * slug: "we-miss-you",
26
+ * lapse_days_min: 60,
27
+ * steps: [
28
+ * { delay_days: 0, template_slug: "wb_hello" },
29
+ * { delay_days: 7, template_slug: "wb_10pct", coupon_kind: "percent", coupon_value: 10 },
30
+ * { delay_days: 14, template_slug: "wb_20pct", coupon_kind: "percent", coupon_value: 20 },
31
+ * ],
32
+ * });
33
+ *
34
+ * // Cron tick — scan orders for customers whose last paid order
35
+ * // landed >= lapse_days_min ago (and <= lapse_days_max if set)
36
+ * // and aren't already enrolled. The scan returns the
37
+ * // (campaign_slug, customer_id) candidates; the operator's
38
+ * // worker then calls enrollCustomer for each.
39
+ * var candidates = await wb.scanForLapsedCustomers({ as_of: Date.now() });
40
+ * for (var i = 0; i < candidates.length; i += 1) {
41
+ * await wb.enrollCustomer({
42
+ * campaign_slug: candidates[i].campaign_slug,
43
+ * customer_id: candidates[i].customer_id,
44
+ * });
45
+ * }
46
+ *
47
+ * // Per-tick dispatcher walks active enrollments whose
48
+ * // next_step_at <= now and advances the FSM.
49
+ * await wb.dispatchTick({ now: Date.now() });
50
+ *
51
+ * // Checkout layer signals the customer paid:
52
+ * await wb.markRecovered({
53
+ * enrollment_id: enr.id,
54
+ * order_id: orderId,
55
+ * });
56
+ *
57
+ * FSM:
58
+ *
59
+ * active --dispatchTick (each step)--> active (next step queued)
60
+ * active --dispatchTick (last step)---> exhausted
61
+ * active --markRecovered---------------> recovered
62
+ * active --cancelEnrollment-----------> cancelled
63
+ * exhausted --markRecovered-----------> recovered (terminal-rewrite)
64
+ *
65
+ * `recovered` / `exhausted` / `cancelled` are terminal. `recovered`
66
+ * trumps `exhausted` so a late-arriving order still counts toward
67
+ * the campaign's recovery rate.
68
+ *
69
+ * Audience gate:
70
+ *
71
+ * `lapse_days_min` is the floor — only customers whose last paid
72
+ * order is at least N days old qualify. `lapse_days_max` is the
73
+ * optional ceiling so the operator can avoid spamming customers
74
+ * who've been gone for years. `audience_filter` carries optional
75
+ * extra predicates (country, lifetime_orders_min) that the scan
76
+ * applies on top of the lapse-window.
77
+ *
78
+ * Monotonic clock:
79
+ *
80
+ * Two delivery writes in the same millisecond would land
81
+ * identical `delivered_at` columns + arrive at the operator's
82
+ * dashboard in non-deterministic order. The `_now()` helper bumps
83
+ * by 1ms on a tie so the per-enrollment timeline stays strictly
84
+ * increasing.
85
+ *
86
+ * Storage:
87
+ * - `winback_campaigns` + `winback_enrollments` +
88
+ * `winback_deliveries` (migration `0196_winback_campaigns.sql`).
89
+ *
90
+ * Composes ONLY blamejs:
91
+ * - `b.uuid.v7` — enrollment + delivery row ids.
92
+ * - `b.guardUuid` — strict UUID gate on every id at the
93
+ * entry point.
94
+ *
95
+ * @primitive winbackCampaigns
96
+ * @related shop.cartRecovery, shop.email, shop.emailSuppressions,
97
+ * shop.customerSegments, b.uuid.v7, b.guardUuid
98
+ */
99
+
100
+ var bShop;
101
+ function _b() {
102
+ if (!bShop) bShop = require("./index");
103
+ return bShop.framework;
104
+ }
105
+
106
+ // ---- constants ----------------------------------------------------------
107
+
108
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
109
+ var TEMPLATE_RE = /^[a-z0-9][a-z0-9._-]{0,126}[a-z0-9]$|^[a-z0-9]$/;
110
+ var MAX_REASON_LEN = 280;
111
+ var MAX_STEPS = 12;
112
+ var DAY_MS = 24 * 60 * 60 * 1000;
113
+
114
+ var ENROLLMENT_STATUSES = Object.freeze([
115
+ "active",
116
+ "recovered",
117
+ "exhausted",
118
+ "cancelled",
119
+ ]);
120
+
121
+ var COUPON_KINDS = Object.freeze([
122
+ "percent",
123
+ "fixed",
124
+ "free_shipping",
125
+ ]);
126
+
127
+ // Recognized audience_filter predicates. Unknown keys throw at
128
+ // defineCampaign time so a typo doesn't silently produce an
129
+ // empty audience.
130
+ var AUDIENCE_KEYS = Object.freeze([
131
+ "country_in",
132
+ "currency_in",
133
+ "lifetime_orders_min",
134
+ "lifetime_orders_max",
135
+ "lifetime_minor_min",
136
+ ]);
137
+
138
+ // ---- monotonic clock ---------------------------------------------------
139
+ //
140
+ // Two delivery writes inside the same millisecond would land
141
+ // identical `delivered_at` columns and the per-enrollment audit
142
+ // timeline would render them in non-deterministic order. Bumping by
143
+ // 1ms on a tie keeps the timeline strictly increasing.
144
+
145
+ var _lastTs = 0;
146
+ function _now() {
147
+ var t = Date.now();
148
+ if (t <= _lastTs) { t = _lastTs + 1; }
149
+ _lastTs = t;
150
+ return t;
151
+ }
152
+
153
+ // ---- validators --------------------------------------------------------
154
+
155
+ function _validateSlug(s, label) {
156
+ if (typeof s !== "string" || !s.length) {
157
+ throw new TypeError("winbackCampaigns: " + label + " must be a non-empty string");
158
+ }
159
+ if (!SLUG_RE.test(s)) {
160
+ throw new TypeError(
161
+ "winbackCampaigns: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
162
+ );
163
+ }
164
+ return s;
165
+ }
166
+
167
+ function _validateTemplateSlug(s, label) {
168
+ if (typeof s !== "string" || !s.length) {
169
+ throw new TypeError("winbackCampaigns: " + label + " must be a non-empty string");
170
+ }
171
+ if (!TEMPLATE_RE.test(s)) {
172
+ throw new TypeError(
173
+ "winbackCampaigns: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
174
+ );
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _validateUuid(s, label) {
180
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
181
+ catch (e) {
182
+ throw new TypeError(
183
+ "winbackCampaigns: " + label + " — " + (e && e.message || "invalid UUID")
184
+ );
185
+ }
186
+ }
187
+
188
+ function _validatePositiveInt(n, label) {
189
+ if (!Number.isInteger(n) || n <= 0) {
190
+ throw new TypeError("winbackCampaigns: " + label + " must be a positive integer");
191
+ }
192
+ return n;
193
+ }
194
+
195
+ function _validateNonNegInt(n, label) {
196
+ if (!Number.isInteger(n) || n < 0) {
197
+ throw new TypeError(
198
+ "winbackCampaigns: " + label + " must be a non-negative integer"
199
+ );
200
+ }
201
+ return n;
202
+ }
203
+
204
+ function _validateReason(s) {
205
+ if (typeof s !== "string" || !s.length) {
206
+ throw new TypeError("winbackCampaigns: reason must be a non-empty string");
207
+ }
208
+ if (s.length > MAX_REASON_LEN) {
209
+ throw new TypeError(
210
+ "winbackCampaigns: reason must be <= " + MAX_REASON_LEN + " characters"
211
+ );
212
+ }
213
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
214
+ throw new TypeError("winbackCampaigns: reason must not contain control bytes");
215
+ }
216
+ return s;
217
+ }
218
+
219
+ function _validateCouponKind(k) {
220
+ if (typeof k !== "string" || COUPON_KINDS.indexOf(k) === -1) {
221
+ throw new TypeError(
222
+ "winbackCampaigns: coupon_kind must be one of " + COUPON_KINDS.join(", ")
223
+ );
224
+ }
225
+ return k;
226
+ }
227
+
228
+ // Steps array — operator declares an ordered list of
229
+ // `{ delay_days, template_slug, coupon_kind?, coupon_value? }`.
230
+ // `delay_days` is the offset from enrollment_created_at (NOT
231
+ // cumulative); the dispatcher computes `next_step_at` as
232
+ // `created_at + cumulative_delay`. delay_days MUST be non-decreasing
233
+ // across steps so the dispatcher never has to walk backwards.
234
+ function _validateSteps(steps) {
235
+ if (!Array.isArray(steps) || steps.length === 0) {
236
+ throw new TypeError("winbackCampaigns: steps must be a non-empty array");
237
+ }
238
+ if (steps.length > MAX_STEPS) {
239
+ throw new TypeError(
240
+ "winbackCampaigns: steps must declare <= " + MAX_STEPS + " entries"
241
+ );
242
+ }
243
+ var out = [];
244
+ for (var i = 0; i < steps.length; i += 1) {
245
+ var s = steps[i];
246
+ if (!s || typeof s !== "object" || Array.isArray(s)) {
247
+ throw new TypeError("winbackCampaigns: steps[" + i + "] must be an object");
248
+ }
249
+ _validateNonNegInt(s.delay_days, "steps[" + i + "].delay_days");
250
+ _validateTemplateSlug(s.template_slug, "steps[" + i + "].template_slug");
251
+ var couponKind = null;
252
+ var couponValue = null;
253
+ if (s.coupon_kind != null) {
254
+ couponKind = _validateCouponKind(s.coupon_kind);
255
+ if (couponKind === "free_shipping") {
256
+ if (s.coupon_value != null) {
257
+ throw new TypeError(
258
+ "winbackCampaigns: steps[" + i + "].coupon_value must be null for free_shipping"
259
+ );
260
+ }
261
+ } else {
262
+ _validatePositiveInt(s.coupon_value, "steps[" + i + "].coupon_value");
263
+ if (couponKind === "percent" && s.coupon_value > 100) {
264
+ throw new TypeError(
265
+ "winbackCampaigns: steps[" + i + "].coupon_value must be <= 100 for percent kind"
266
+ );
267
+ }
268
+ couponValue = s.coupon_value;
269
+ }
270
+ } else if (s.coupon_value != null) {
271
+ throw new TypeError(
272
+ "winbackCampaigns: steps[" + i + "].coupon_value supplied without coupon_kind"
273
+ );
274
+ }
275
+ out.push({
276
+ delay_days: s.delay_days,
277
+ template_slug: s.template_slug,
278
+ coupon_kind: couponKind,
279
+ coupon_value: couponValue,
280
+ });
281
+ }
282
+ return out;
283
+ }
284
+
285
+ function _validateAudienceFilter(filter) {
286
+ if (filter == null) return {};
287
+ if (typeof filter !== "object" || Array.isArray(filter)) {
288
+ throw new TypeError(
289
+ "winbackCampaigns: audience_filter must be an object when supplied"
290
+ );
291
+ }
292
+ var keys = Object.keys(filter);
293
+ for (var i = 0; i < keys.length; i += 1) {
294
+ if (AUDIENCE_KEYS.indexOf(keys[i]) === -1) {
295
+ throw new TypeError(
296
+ "winbackCampaigns: audience_filter key '" + keys[i] +
297
+ "' is not recognized (allowed: " + AUDIENCE_KEYS.join(", ") + ")"
298
+ );
299
+ }
300
+ }
301
+ if (filter.country_in != null) {
302
+ if (!Array.isArray(filter.country_in) || !filter.country_in.length) {
303
+ throw new TypeError("winbackCampaigns: audience_filter.country_in must be a non-empty array");
304
+ }
305
+ for (var ci = 0; ci < filter.country_in.length; ci += 1) {
306
+ var c = filter.country_in[ci];
307
+ if (typeof c !== "string" || !/^[A-Z]{2}$/.test(c)) {
308
+ throw new TypeError(
309
+ "winbackCampaigns: audience_filter.country_in[" + ci + "] must be a 2-letter ISO uppercase code"
310
+ );
311
+ }
312
+ }
313
+ }
314
+ if (filter.currency_in != null) {
315
+ if (!Array.isArray(filter.currency_in) || !filter.currency_in.length) {
316
+ throw new TypeError("winbackCampaigns: audience_filter.currency_in must be a non-empty array");
317
+ }
318
+ for (var ki = 0; ki < filter.currency_in.length; ki += 1) {
319
+ var cu = filter.currency_in[ki];
320
+ if (typeof cu !== "string" || !/^[A-Z]{3}$/.test(cu)) {
321
+ throw new TypeError(
322
+ "winbackCampaigns: audience_filter.currency_in[" + ki + "] must be a 3-letter ISO uppercase code"
323
+ );
324
+ }
325
+ }
326
+ }
327
+ if (filter.lifetime_orders_min != null) {
328
+ _validatePositiveInt(filter.lifetime_orders_min, "audience_filter.lifetime_orders_min");
329
+ }
330
+ if (filter.lifetime_orders_max != null) {
331
+ _validatePositiveInt(filter.lifetime_orders_max, "audience_filter.lifetime_orders_max");
332
+ }
333
+ if (filter.lifetime_minor_min != null) {
334
+ _validatePositiveInt(filter.lifetime_minor_min, "audience_filter.lifetime_minor_min");
335
+ }
336
+ return filter;
337
+ }
338
+
339
+ // ---- row → public shape -------------------------------------------------
340
+
341
+ function _rowToCampaign(row) {
342
+ if (!row) return null;
343
+ var steps;
344
+ try { steps = JSON.parse(row.steps_json); }
345
+ catch (_e) {
346
+ // drop-silent — a malformed JSON column would be a write-side
347
+ // corruption we surface as the operator-readable empty shape so
348
+ // the dashboard renders rather than crashes.
349
+ steps = [];
350
+ }
351
+ var audience;
352
+ try { audience = JSON.parse(row.audience_filter_json || "{}"); }
353
+ catch (_e) { audience = {}; }
354
+ return {
355
+ slug: row.slug,
356
+ lapse_days_min: Number(row.lapse_days_min),
357
+ lapse_days_max: row.lapse_days_max == null ? null : Number(row.lapse_days_max),
358
+ steps: steps,
359
+ audience_filter: audience,
360
+ archived_at: row.archived_at == null ? null : Number(row.archived_at),
361
+ created_at: Number(row.created_at),
362
+ updated_at: Number(row.updated_at),
363
+ };
364
+ }
365
+
366
+ function _rowToEnrollment(row) {
367
+ if (!row) return null;
368
+ return {
369
+ id: row.id,
370
+ campaign_slug: row.campaign_slug,
371
+ customer_id: row.customer_id,
372
+ status: row.status,
373
+ current_step_index: Number(row.current_step_index),
374
+ next_step_at: row.next_step_at == null ? null : Number(row.next_step_at),
375
+ recovered_order_id: row.recovered_order_id || null,
376
+ recovered_at: row.recovered_at == null ? null : Number(row.recovered_at),
377
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
378
+ cancelled_reason: row.cancelled_reason || null,
379
+ created_at: Number(row.created_at),
380
+ updated_at: Number(row.updated_at),
381
+ };
382
+ }
383
+
384
+ function _rowToDelivery(row) {
385
+ if (!row) return null;
386
+ return {
387
+ id: row.id,
388
+ enrollment_id: row.enrollment_id,
389
+ step_index: Number(row.step_index),
390
+ coupon_code: row.coupon_code || null,
391
+ delivered_at: Number(row.delivered_at),
392
+ };
393
+ }
394
+
395
+ // ---- factory ------------------------------------------------------------
396
+
397
+ function create(opts) {
398
+ opts = opts || {};
399
+
400
+ var query = opts.query;
401
+ if (!query) {
402
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
403
+ }
404
+
405
+ // Optional dep: read-only handle on the operator's order primitive.
406
+ // The scan call also walks the `orders` table directly through
407
+ // `query` — when `order` is wired the scan can defer to a richer
408
+ // surface in the future. Held for forward-compatibility.
409
+ var order = opts.order || null;
410
+ if (order && typeof order !== "object") {
411
+ throw new TypeError(
412
+ "winbackCampaigns.create: opts.order must be an object exposing the order primitive"
413
+ );
414
+ }
415
+
416
+ // Optional dep: customer-segments primitive. When wired, the
417
+ // audience_filter can narrow on operator-defined segments by
418
+ // intersecting with segment membership. Held for forward-
419
+ // compatibility.
420
+ var customerSegments = opts.customerSegments || null;
421
+ if (customerSegments && typeof customerSegments !== "object") {
422
+ throw new TypeError(
423
+ "winbackCampaigns.create: opts.customerSegments must be an object exposing the customerSegments primitive"
424
+ );
425
+ }
426
+
427
+ // Optional dep: email primitive. When wired, dispatchTick routes
428
+ // each step through the matching send verb; absent, the dispatcher
429
+ // still walks the FSM + writes the delivery log but skips the
430
+ // actual send (the operator's worker performs the send after
431
+ // reading the enrollment row).
432
+ var email = opts.email || null;
433
+ if (email && typeof email !== "object") {
434
+ throw new TypeError(
435
+ "winbackCampaigns.create: opts.email must be an object exposing the email primitive"
436
+ );
437
+ }
438
+
439
+ // Optional dep: email-suppressions primitive. When wired, the
440
+ // dispatcher consults `isSuppressed` before each send + cancels
441
+ // the enrollment on a hit (the customer asked to be left alone).
442
+ var emailSuppressions = opts.emailSuppressions || null;
443
+ if (emailSuppressions && typeof emailSuppressions.isSuppressed !== "function") {
444
+ throw new TypeError(
445
+ "winbackCampaigns.create: opts.emailSuppressions must expose an isSuppressed(input) method"
446
+ );
447
+ }
448
+
449
+ // Optional dep: coupons primitive. When wired + a step declares
450
+ // `coupon_kind`, the dispatcher mints a per-delivery code via
451
+ // `coupons.mint({ kind, value, customer_id })`. Absent, the
452
+ // delivery row records the kind+value the operator's worker can
453
+ // honor without a pre-issued code.
454
+ var coupons = opts.coupons || null;
455
+ if (coupons && typeof coupons !== "object") {
456
+ throw new TypeError(
457
+ "winbackCampaigns.create: opts.coupons must be an object exposing the coupons primitive"
458
+ );
459
+ }
460
+
461
+ // ---- internals ------------------------------------------------------
462
+
463
+ async function _getCampaignRow(slug) {
464
+ var r = await query(
465
+ "SELECT * FROM winback_campaigns WHERE slug = ?1 LIMIT 1",
466
+ [slug],
467
+ );
468
+ return r.rows[0] || null;
469
+ }
470
+
471
+ async function _getEnrollmentRow(id) {
472
+ var r = await query(
473
+ "SELECT * FROM winback_enrollments WHERE id = ?1 LIMIT 1",
474
+ [id],
475
+ );
476
+ return r.rows[0] || null;
477
+ }
478
+
479
+ // Compute the absolute next_step_at for a given step index using
480
+ // the campaign's steps array + the enrollment's created_at as the
481
+ // anchor. Each step's delay_days accumulates so a [0, 7, 14] array
482
+ // yields next_step_at offsets of 0d / 7d / 14d (NOT 0d / 7d / 21d).
483
+ function _nextStepAt(steps, createdAt, stepIdx) {
484
+ if (!Array.isArray(steps) || stepIdx >= steps.length) return null;
485
+ var cumulative = 0;
486
+ for (var i = 0; i <= stepIdx; i += 1) {
487
+ cumulative += steps[i].delay_days;
488
+ }
489
+ return createdAt + cumulative * DAY_MS;
490
+ }
491
+
492
+ // Resolve a coupon code for the step. When `coupons` is wired and
493
+ // the step declares a coupon, mint a fresh code through the
494
+ // coupons primitive; otherwise return null (the operator's worker
495
+ // can honor the step's declared kind+value without a code).
496
+ async function _resolveCouponForStep(step, customerId) {
497
+ if (!coupons || !step.coupon_kind) return null;
498
+ if (typeof coupons.mint !== "function") return null;
499
+ try {
500
+ var minted = await coupons.mint({
501
+ kind: step.coupon_kind,
502
+ value: step.coupon_value,
503
+ customer_id: customerId,
504
+ });
505
+ if (minted && typeof minted.code === "string" && minted.code.length) {
506
+ return minted.code;
507
+ }
508
+ } catch (_e) {
509
+ // drop-silent — a coupons outage shouldn't block the operator's
510
+ // winback campaign. The delivery row still writes with a null
511
+ // coupon_code; the operator's worker can honor the step's
512
+ // declared kind+value.
513
+ }
514
+ return null;
515
+ }
516
+
517
+ // ---- surface --------------------------------------------------------
518
+
519
+ return {
520
+ ENROLLMENT_STATUSES: ENROLLMENT_STATUSES,
521
+ COUPON_KINDS: COUPON_KINDS,
522
+
523
+ // Define / redefine an operator-owned campaign. The slug is the
524
+ // primary key; redefining replaces the steps + bumps updated_at.
525
+ // Existing enrollments KEEP the steps they were enrolled under —
526
+ // the dispatcher re-reads steps_json at dispatch time, so
527
+ // changing the campaign re-shapes future deliveries for already-
528
+ // active enrollments too. (Operators who want the older shape
529
+ // pinned should slug-version the campaign.)
530
+ defineCampaign: async function (input) {
531
+ if (!input || typeof input !== "object") {
532
+ throw new TypeError("winbackCampaigns.defineCampaign: input object required");
533
+ }
534
+ var slug = _validateSlug(input.slug, "slug");
535
+ var lapseDaysMin = _validatePositiveInt(input.lapse_days_min, "lapse_days_min");
536
+ var lapseDaysMax = null;
537
+ if (input.lapse_days_max != null) {
538
+ lapseDaysMax = _validatePositiveInt(input.lapse_days_max, "lapse_days_max");
539
+ if (lapseDaysMax < lapseDaysMin) {
540
+ throw new TypeError(
541
+ "winbackCampaigns.defineCampaign: lapse_days_max (" + lapseDaysMax +
542
+ ") must be >= lapse_days_min (" + lapseDaysMin + ")"
543
+ );
544
+ }
545
+ }
546
+ var steps = _validateSteps(input.steps);
547
+ var audience = _validateAudienceFilter(input.audience_filter);
548
+
549
+ var now = input.now == null ? _now() : input.now;
550
+ _validateNonNegInt(now, "now");
551
+
552
+ var existing = await _getCampaignRow(slug);
553
+ if (!existing) {
554
+ await query(
555
+ "INSERT INTO winback_campaigns " +
556
+ "(slug, lapse_days_min, lapse_days_max, steps_json, audience_filter_json, created_at, updated_at) " +
557
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6)",
558
+ [slug, lapseDaysMin, lapseDaysMax, JSON.stringify(steps), JSON.stringify(audience), now],
559
+ );
560
+ } else {
561
+ await query(
562
+ "UPDATE winback_campaigns SET " +
563
+ "lapse_days_min = ?1, lapse_days_max = ?2, steps_json = ?3, " +
564
+ "audience_filter_json = ?4, updated_at = ?5 WHERE slug = ?6",
565
+ [lapseDaysMin, lapseDaysMax, JSON.stringify(steps), JSON.stringify(audience), now, slug],
566
+ );
567
+ }
568
+ return _rowToCampaign(await _getCampaignRow(slug));
569
+ },
570
+
571
+ // Archive a campaign — soft-deletes it from the scan path
572
+ // without dropping historical enrollments. The dispatcher still
573
+ // walks active enrollments under an archived campaign (the
574
+ // operator told these customers they'd hear back, so the
575
+ // sequence completes).
576
+ archiveCampaign: async function (slug, archiveOpts) {
577
+ _validateSlug(slug, "slug");
578
+ archiveOpts = archiveOpts || {};
579
+ var now = archiveOpts.now == null ? _now() : archiveOpts.now;
580
+ _validateNonNegInt(now, "now");
581
+ var existing = await _getCampaignRow(slug);
582
+ if (!existing) {
583
+ throw new TypeError(
584
+ "winbackCampaigns.archiveCampaign: campaign '" + slug + "' not found"
585
+ );
586
+ }
587
+ if (existing.archived_at != null) {
588
+ return _rowToCampaign(existing);
589
+ }
590
+ await query(
591
+ "UPDATE winback_campaigns SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
592
+ [now, slug],
593
+ );
594
+ return _rowToCampaign(await _getCampaignRow(slug));
595
+ },
596
+
597
+ // Scan the operator's order history for customers whose last
598
+ // paid order landed inside the lapse window for each active
599
+ // campaign + who aren't already enrolled. Returns a flat array
600
+ // of `{ campaign_slug, customer_id, last_order_at, lifetime_orders,
601
+ // lifetime_minor }` candidates; the operator's worker then calls
602
+ // enrollCustomer for each. Scoped by `as_of` so a deterministic
603
+ // backfill walks the same window across retries.
604
+ //
605
+ // The scan uses the operator's `orders` table — every paid order
606
+ // (status IN paid/fulfilling/shipped/delivered) counts toward the
607
+ // lifetime aggregate; refunded / cancelled / pending orders are
608
+ // excluded. customer_id IS NULL guests are skipped (they have no
609
+ // account to nurture).
610
+ scanForLapsedCustomers: async function (scanOpts) {
611
+ scanOpts = scanOpts || {};
612
+ var asOf = scanOpts.as_of == null ? _now() : scanOpts.as_of;
613
+ _validateNonNegInt(asOf, "as_of");
614
+ var maxBatch = scanOpts.max_batch == null ? 500 : scanOpts.max_batch;
615
+ _validatePositiveInt(maxBatch, "max_batch");
616
+
617
+ // Active campaigns only.
618
+ var campaignRows = (await query(
619
+ "SELECT * FROM winback_campaigns WHERE archived_at IS NULL ORDER BY created_at ASC",
620
+ [],
621
+ )).rows;
622
+ if (!campaignRows.length) return [];
623
+
624
+ // Aggregate each customer's last_paid_order_at + lifetime
625
+ // counts. The `customer_id IS NOT NULL` gate skips guests.
626
+ var aggRows = (await query(
627
+ "SELECT customer_id, " +
628
+ " MAX(created_at) AS last_paid_at, " +
629
+ " COUNT(*) AS order_count, " +
630
+ " SUM(grand_total_minor) AS lifetime_minor " +
631
+ "FROM orders " +
632
+ "WHERE customer_id IS NOT NULL " +
633
+ " AND status IN ('paid', 'fulfilling', 'shipped', 'delivered') " +
634
+ "GROUP BY customer_id",
635
+ [],
636
+ )).rows;
637
+
638
+ // For each (customer, campaign), check lapse window + audience
639
+ // filter + not-already-enrolled. The "not already enrolled"
640
+ // check is a single read per (customer, campaign) pair — for
641
+ // very large customer bases the operator would precompute the
642
+ // exclusion set, but the per-pair read keeps the surface
643
+ // straightforward + correct.
644
+ var out = [];
645
+ for (var ci = 0; ci < campaignRows.length; ci += 1) {
646
+ var c = campaignRows[ci];
647
+ var minMs = Number(c.lapse_days_min) * DAY_MS;
648
+ var maxMs = c.lapse_days_max == null ? null : Number(c.lapse_days_max) * DAY_MS;
649
+ var audience;
650
+ try { audience = JSON.parse(c.audience_filter_json || "{}"); }
651
+ catch (_e) { audience = {}; }
652
+
653
+ for (var ai = 0; ai < aggRows.length; ai += 1) {
654
+ if (out.length >= maxBatch) break;
655
+ var agg = aggRows[ai];
656
+ var lastAt = Number(agg.last_paid_at);
657
+ var orderN = Number(agg.order_count);
658
+ var lifetime = Number(agg.lifetime_minor || 0);
659
+ var ageMs = asOf - lastAt;
660
+ if (ageMs < minMs) continue;
661
+ if (maxMs != null && ageMs > maxMs) continue;
662
+
663
+ if (audience.lifetime_orders_min != null && orderN < audience.lifetime_orders_min) continue;
664
+ if (audience.lifetime_orders_max != null && orderN > audience.lifetime_orders_max) continue;
665
+ if (audience.lifetime_minor_min != null && lifetime < audience.lifetime_minor_min) continue;
666
+
667
+ // country_in / currency_in narrow on the last paid order's
668
+ // ship-to / currency. We read those columns on demand so
669
+ // the aggregate query stays cheap when the gates aren't
670
+ // declared.
671
+ if (audience.country_in != null || audience.currency_in != null) {
672
+ var lastRow = (await query(
673
+ "SELECT currency, ship_to_json FROM orders " +
674
+ "WHERE customer_id = ?1 AND created_at = ?2 " +
675
+ " AND status IN ('paid', 'fulfilling', 'shipped', 'delivered') " +
676
+ "LIMIT 1",
677
+ [agg.customer_id, lastAt],
678
+ )).rows[0];
679
+ if (!lastRow) continue;
680
+ if (audience.currency_in != null) {
681
+ if (audience.currency_in.indexOf(lastRow.currency) === -1) continue;
682
+ }
683
+ if (audience.country_in != null) {
684
+ var shipTo;
685
+ try { shipTo = JSON.parse(lastRow.ship_to_json || "{}"); }
686
+ catch (_e) { shipTo = {}; }
687
+ var country = shipTo && shipTo.country ? String(shipTo.country) : "";
688
+ if (audience.country_in.indexOf(country) === -1) continue;
689
+ }
690
+ }
691
+
692
+ // Skip already-enrolled (active OR terminal — re-enrolling
693
+ // a customer who already finished one round of the same
694
+ // campaign would feel spammy; the operator can define a
695
+ // sibling campaign slug for a second pass).
696
+ var exists = (await query(
697
+ "SELECT id FROM winback_enrollments WHERE customer_id = ?1 AND campaign_slug = ?2 LIMIT 1",
698
+ [agg.customer_id, c.slug],
699
+ )).rows[0];
700
+ if (exists) continue;
701
+
702
+ out.push({
703
+ campaign_slug: c.slug,
704
+ customer_id: agg.customer_id,
705
+ last_order_at: lastAt,
706
+ lifetime_orders: orderN,
707
+ lifetime_minor: lifetime,
708
+ });
709
+ }
710
+ if (out.length >= maxBatch) break;
711
+ }
712
+ return out;
713
+ },
714
+
715
+ // Enroll a customer into a campaign. Schedules `next_step_at` at
716
+ // `created_at + steps[0].delay_days * 24h`. Idempotent at the
717
+ // (campaign_slug, customer_id) pair — re-enrolling returns the
718
+ // existing row without rewriting it (the UNIQUE index would
719
+ // refuse the INSERT regardless; this surface returns the
720
+ // existing enrollment so the operator's worker can chain).
721
+ enrollCustomer: async function (input) {
722
+ if (!input || typeof input !== "object") {
723
+ throw new TypeError("winbackCampaigns.enrollCustomer: input object required");
724
+ }
725
+ var campaignSlug = _validateSlug(input.campaign_slug, "campaign_slug");
726
+ var customerId = _validateUuid(input.customer_id, "customer_id");
727
+
728
+ var now = input.now == null ? _now() : input.now;
729
+ _validateNonNegInt(now, "now");
730
+
731
+ var camp = await _getCampaignRow(campaignSlug);
732
+ if (!camp) {
733
+ throw new TypeError(
734
+ "winbackCampaigns.enrollCustomer: campaign '" + campaignSlug + "' not found"
735
+ );
736
+ }
737
+ if (camp.archived_at != null) {
738
+ throw new TypeError(
739
+ "winbackCampaigns.enrollCustomer: campaign '" + campaignSlug + "' is archived"
740
+ );
741
+ }
742
+ var steps;
743
+ try { steps = JSON.parse(camp.steps_json); }
744
+ catch (_e) {
745
+ throw new TypeError(
746
+ "winbackCampaigns.enrollCustomer: campaign '" + campaignSlug + "' has malformed steps_json"
747
+ );
748
+ }
749
+ if (!Array.isArray(steps) || steps.length === 0) {
750
+ throw new TypeError(
751
+ "winbackCampaigns.enrollCustomer: campaign '" + campaignSlug + "' has no steps"
752
+ );
753
+ }
754
+
755
+ var existing = (await query(
756
+ "SELECT * FROM winback_enrollments WHERE customer_id = ?1 AND campaign_slug = ?2 LIMIT 1",
757
+ [customerId, campaignSlug],
758
+ )).rows[0];
759
+ if (existing) {
760
+ return _rowToEnrollment(existing);
761
+ }
762
+
763
+ var id = _b().uuid.v7();
764
+ var nextStepAt = _nextStepAt(steps, now, 0);
765
+ await query(
766
+ "INSERT INTO winback_enrollments " +
767
+ "(id, campaign_slug, customer_id, status, current_step_index, " +
768
+ " next_step_at, created_at, updated_at) " +
769
+ "VALUES (?1, ?2, ?3, 'active', 0, ?4, ?5, ?5)",
770
+ [id, campaignSlug, customerId, nextStepAt, now],
771
+ );
772
+ return _rowToEnrollment(await _getEnrollmentRow(id));
773
+ },
774
+
775
+ // Cron-driven dispatcher. Walks active enrollments with
776
+ // `next_step_at <= now`, fires the current step (writes a
777
+ // delivery row + optional coupon code + optional email send),
778
+ // then advances `current_step_index` + `next_step_at`. Returns
779
+ // the list of deliveries written so the caller can fan-out a
780
+ // per-step notification without re-reading the table.
781
+ dispatchTick: async function (tickOpts) {
782
+ tickOpts = tickOpts || {};
783
+ var now = tickOpts.now == null ? _now() : tickOpts.now;
784
+ _validateNonNegInt(now, "now");
785
+ var maxBatch = tickOpts.batch_size == null ? 500 : tickOpts.batch_size;
786
+ _validatePositiveInt(maxBatch, "batch_size");
787
+
788
+ // Optional "resolve a deliverable email from the customer_id"
789
+ // hook. The operator wires customer_id → email here (typically
790
+ // via the customers primitive) so the dispatcher can ask the
791
+ // suppressions gate + route through the email primitive.
792
+ // Absent the resolver the dispatcher still walks the FSM +
793
+ // writes delivery rows; the operator's worker performs the
794
+ // send after reading the enrollment.
795
+ var resolveEmail = tickOpts.resolveEmail || null;
796
+ if (resolveEmail != null && typeof resolveEmail !== "function") {
797
+ throw new TypeError(
798
+ "winbackCampaigns.dispatchTick: resolveEmail must be a function (enrollment) => Promise<string|null>"
799
+ );
800
+ }
801
+
802
+ var due = (await query(
803
+ "SELECT * FROM winback_enrollments " +
804
+ "WHERE status = 'active' AND next_step_at IS NOT NULL AND next_step_at <= ?1 " +
805
+ "ORDER BY next_step_at ASC LIMIT ?2",
806
+ [now, maxBatch],
807
+ )).rows;
808
+
809
+ var deliveries = [];
810
+
811
+ for (var i = 0; i < due.length; i += 1) {
812
+ var enr = due[i];
813
+ var camp = await _getCampaignRow(enr.campaign_slug);
814
+ if (!camp) {
815
+ // Campaign vanished out from under us. Cancel the
816
+ // enrollment so the dispatcher doesn't loop on a row it
817
+ // can't service.
818
+ await query(
819
+ "UPDATE winback_enrollments SET " +
820
+ "status = 'cancelled', next_step_at = NULL, " +
821
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
822
+ "WHERE id = ?3 AND status = 'active'",
823
+ [now, "campaign-missing", enr.id],
824
+ );
825
+ continue;
826
+ }
827
+ var steps;
828
+ try { steps = JSON.parse(camp.steps_json); }
829
+ catch (_e) { steps = []; }
830
+
831
+ var stepIdx = Number(enr.current_step_index);
832
+ if (stepIdx >= steps.length) {
833
+ // Sequence shrank under our feet. Land `exhausted`.
834
+ await query(
835
+ "UPDATE winback_enrollments SET " +
836
+ "status = 'exhausted', next_step_at = NULL, updated_at = ?1 " +
837
+ "WHERE id = ?2 AND status = 'active'",
838
+ [now, enr.id],
839
+ );
840
+ continue;
841
+ }
842
+
843
+ var step = steps[stepIdx];
844
+ var customerId = enr.customer_id;
845
+
846
+ // Optional suppression gate. The dispatcher consults
847
+ // `isSuppressed` only when a resolver returned an address;
848
+ // absent the resolver the suppression gate doesn't run (the
849
+ // operator's worker is responsible for the deliverability
850
+ // check at send time).
851
+ var resolvedEmail = null;
852
+ var suppressed = false;
853
+ var suppressionReason = null;
854
+ if (resolveEmail) {
855
+ try { resolvedEmail = await resolveEmail(_rowToEnrollment(enr)); }
856
+ catch (_e) {
857
+ // drop-silent — a resolver outage shouldn't block the
858
+ // operator's campaign. The dispatcher writes the
859
+ // delivery row + advances the FSM.
860
+ resolvedEmail = null;
861
+ }
862
+ }
863
+ if (resolvedEmail && emailSuppressions) {
864
+ try {
865
+ var sup = await emailSuppressions.isSuppressed({
866
+ email: resolvedEmail,
867
+ scope: "marketing",
868
+ });
869
+ if (sup && sup.suppressed) {
870
+ suppressed = true;
871
+ suppressionReason = sup.suppression_type || "suppressed";
872
+ }
873
+ } catch (_e) {
874
+ // drop-silent — suppressions outage errs toward sending
875
+ // (the operator's mailer is the next gate).
876
+ }
877
+ }
878
+
879
+ if (suppressed) {
880
+ // Cancel — the customer's preference applies to every
881
+ // future step too.
882
+ await query(
883
+ "UPDATE winback_enrollments SET " +
884
+ "status = 'cancelled', next_step_at = NULL, " +
885
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
886
+ "WHERE id = ?3 AND status = 'active'",
887
+ [now, suppressionReason || "suppressed", enr.id],
888
+ );
889
+ continue;
890
+ }
891
+
892
+ // Mint a coupon for the step when applicable + route the
893
+ // send through the operator's email primitive. The actual
894
+ // send is a best-effort hook; failure here doesn't block the
895
+ // delivery row (the operator's worker can retry from the
896
+ // enrollment row).
897
+ var couponCode = await _resolveCouponForStep(step, customerId);
898
+ if (
899
+ email &&
900
+ resolvedEmail &&
901
+ typeof email.send === "function"
902
+ ) {
903
+ try {
904
+ await email.send({
905
+ to: resolvedEmail,
906
+ template_slug: step.template_slug,
907
+ variables: {
908
+ customer_id: customerId,
909
+ coupon_code: couponCode,
910
+ coupon_kind: step.coupon_kind,
911
+ coupon_value: step.coupon_value,
912
+ },
913
+ });
914
+ } catch (_e) {
915
+ // drop-silent — the delivery row + FSM advance still
916
+ // commit; the operator's mailer retries from the
917
+ // delivery row's coupon_code if it minted one.
918
+ }
919
+ }
920
+
921
+ // Idempotency: if a delivery row already exists for this
922
+ // (enrollment, step_index) pair we don't write a second
923
+ // one. The UNIQUE index on (enrollment_id, step_index)
924
+ // backstops this with a hard constraint at the storage
925
+ // layer.
926
+ var prior = await query(
927
+ "SELECT id FROM winback_deliveries WHERE enrollment_id = ?1 AND step_index = ?2 LIMIT 1",
928
+ [enr.id, stepIdx],
929
+ );
930
+ if (!prior.rows[0]) {
931
+ var deliveryId = _b().uuid.v7();
932
+ await query(
933
+ "INSERT INTO winback_deliveries " +
934
+ "(id, enrollment_id, step_index, coupon_code, delivered_at) " +
935
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
936
+ [deliveryId, enr.id, stepIdx, couponCode, now],
937
+ );
938
+ }
939
+
940
+ // Advance the FSM.
941
+ var nextIdx = stepIdx + 1;
942
+ if (nextIdx >= steps.length) {
943
+ await query(
944
+ "UPDATE winback_enrollments SET " +
945
+ "status = 'exhausted', current_step_index = ?1, " +
946
+ "next_step_at = NULL, updated_at = ?2 " +
947
+ "WHERE id = ?3 AND status = 'active' AND current_step_index = ?4",
948
+ [nextIdx, now, enr.id, stepIdx],
949
+ );
950
+ } else {
951
+ var createdAt = Number(enr.created_at);
952
+ var nextAt = _nextStepAt(steps, createdAt, nextIdx);
953
+ await query(
954
+ "UPDATE winback_enrollments SET " +
955
+ "current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
956
+ "WHERE id = ?4 AND status = 'active' AND current_step_index = ?5",
957
+ [nextIdx, nextAt, now, enr.id, stepIdx],
958
+ );
959
+ }
960
+
961
+ deliveries.push({
962
+ enrollment_id: enr.id,
963
+ step_index: stepIdx,
964
+ coupon_code: couponCode,
965
+ delivered_at: now,
966
+ });
967
+ }
968
+
969
+ return {
970
+ dispatched: deliveries.length,
971
+ rows: deliveries,
972
+ };
973
+ },
974
+
975
+ // Direct-record path for callers that drive the send themselves
976
+ // and just want the delivery log + FSM advance. Idempotent at
977
+ // the (enrollment_id, step_index) pair — re-recording the same
978
+ // step is a no-op.
979
+ recordStepDelivery: async function (input) {
980
+ if (!input || typeof input !== "object") {
981
+ throw new TypeError("winbackCampaigns.recordStepDelivery: input object required");
982
+ }
983
+ var enrollmentId = _validateUuid(input.enrollment_id, "enrollment_id");
984
+ _validateNonNegInt(input.step_index, "step_index");
985
+ var deliveredAt = input.delivered_at == null ? _now() : input.delivered_at;
986
+ _validateNonNegInt(deliveredAt, "delivered_at");
987
+ var couponCode = null;
988
+ if (input.coupon_code != null) {
989
+ if (typeof input.coupon_code !== "string" || !input.coupon_code.length) {
990
+ throw new TypeError("winbackCampaigns.recordStepDelivery: coupon_code must be a non-empty string");
991
+ }
992
+ if (input.coupon_code.length > 64) {
993
+ throw new TypeError("winbackCampaigns.recordStepDelivery: coupon_code must be <= 64 characters");
994
+ }
995
+ if (/[\x00-\x1f\x7f\s]/.test(input.coupon_code)) {
996
+ throw new TypeError("winbackCampaigns.recordStepDelivery: coupon_code must not contain whitespace or control bytes");
997
+ }
998
+ couponCode = input.coupon_code;
999
+ }
1000
+
1001
+ var enr = await _getEnrollmentRow(enrollmentId);
1002
+ if (!enr) {
1003
+ throw new TypeError(
1004
+ "winbackCampaigns.recordStepDelivery: enrollment '" + enrollmentId + "' not found"
1005
+ );
1006
+ }
1007
+ if (enr.status !== "active") {
1008
+ return {
1009
+ enrollment_id: enrollmentId,
1010
+ changed: false,
1011
+ status: enr.status,
1012
+ };
1013
+ }
1014
+ if (Number(enr.current_step_index) !== input.step_index) {
1015
+ throw new TypeError(
1016
+ "winbackCampaigns.recordStepDelivery: step_index " + input.step_index +
1017
+ " does not match enrollment's current_step_index " + enr.current_step_index
1018
+ );
1019
+ }
1020
+
1021
+ var camp = await _getCampaignRow(enr.campaign_slug);
1022
+ if (!camp) {
1023
+ throw new TypeError(
1024
+ "winbackCampaigns.recordStepDelivery: campaign '" + enr.campaign_slug + "' not found"
1025
+ );
1026
+ }
1027
+ var steps;
1028
+ try { steps = JSON.parse(camp.steps_json); }
1029
+ catch (_e) { steps = []; }
1030
+
1031
+ var prior = await query(
1032
+ "SELECT id FROM winback_deliveries WHERE enrollment_id = ?1 AND step_index = ?2 LIMIT 1",
1033
+ [enrollmentId, input.step_index],
1034
+ );
1035
+ if (!prior.rows[0]) {
1036
+ var deliveryId = _b().uuid.v7();
1037
+ await query(
1038
+ "INSERT INTO winback_deliveries " +
1039
+ "(id, enrollment_id, step_index, coupon_code, delivered_at) " +
1040
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
1041
+ [deliveryId, enrollmentId, input.step_index, couponCode, deliveredAt],
1042
+ );
1043
+ }
1044
+
1045
+ var nextIdx = input.step_index + 1;
1046
+ if (nextIdx >= steps.length) {
1047
+ await query(
1048
+ "UPDATE winback_enrollments SET " +
1049
+ "status = 'exhausted', current_step_index = ?1, " +
1050
+ "next_step_at = NULL, updated_at = ?2 " +
1051
+ "WHERE id = ?3 AND status = 'active'",
1052
+ [nextIdx, deliveredAt, enrollmentId],
1053
+ );
1054
+ } else {
1055
+ var createdAt = Number(enr.created_at);
1056
+ var nextAt = _nextStepAt(steps, createdAt, nextIdx);
1057
+ await query(
1058
+ "UPDATE winback_enrollments SET " +
1059
+ "current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
1060
+ "WHERE id = ?4 AND status = 'active'",
1061
+ [nextIdx, nextAt, deliveredAt, enrollmentId],
1062
+ );
1063
+ }
1064
+
1065
+ return {
1066
+ enrollment_id: enrollmentId,
1067
+ changed: true,
1068
+ next_step_index: nextIdx,
1069
+ };
1070
+ },
1071
+
1072
+ // Checkout layer signals "customer placed an order while the
1073
+ // campaign was nurturing them." The enrollment lands `recovered`
1074
+ // (terminal). Rewrites a prior `exhausted` row too — the order
1075
+ // trumps the calendar since the operator's metrics treat
1076
+ // recovery as the success outcome regardless of whether every
1077
+ // step had already fired.
1078
+ markRecovered: async function (input) {
1079
+ if (!input || typeof input !== "object") {
1080
+ throw new TypeError("winbackCampaigns.markRecovered: input object required");
1081
+ }
1082
+ var enrollmentId = _validateUuid(input.enrollment_id, "enrollment_id");
1083
+ var orderId = _validateUuid(input.order_id, "order_id");
1084
+ var now = input.recovered_at == null ? _now() : input.recovered_at;
1085
+ _validateNonNegInt(now, "recovered_at");
1086
+
1087
+ var enr = await _getEnrollmentRow(enrollmentId);
1088
+ if (!enr) {
1089
+ throw new TypeError(
1090
+ "winbackCampaigns.markRecovered: enrollment '" + enrollmentId + "' not found"
1091
+ );
1092
+ }
1093
+ if (enr.status === "recovered") {
1094
+ return {
1095
+ enrollment_id: enrollmentId,
1096
+ changed: false,
1097
+ status: "recovered",
1098
+ };
1099
+ }
1100
+ if (enr.status === "cancelled") {
1101
+ throw new TypeError(
1102
+ "winbackCampaigns.markRecovered: enrollment '" + enrollmentId +
1103
+ "' is cancelled — cannot transition to recovered"
1104
+ );
1105
+ }
1106
+
1107
+ await query(
1108
+ "UPDATE winback_enrollments SET " +
1109
+ "status = 'recovered', next_step_at = NULL, " +
1110
+ "recovered_at = ?1, recovered_order_id = ?2, updated_at = ?1 " +
1111
+ "WHERE id = ?3 AND status IN ('active', 'exhausted')",
1112
+ [now, orderId, enrollmentId],
1113
+ );
1114
+
1115
+ return {
1116
+ enrollment_id: enrollmentId,
1117
+ changed: true,
1118
+ status: "recovered",
1119
+ order_id: orderId,
1120
+ };
1121
+ },
1122
+
1123
+ // Operator manual cancel — pulls the enrollment out of the
1124
+ // dispatch queue without a send.
1125
+ cancelEnrollment: async function (input) {
1126
+ if (!input || typeof input !== "object") {
1127
+ throw new TypeError("winbackCampaigns.cancelEnrollment: input object required");
1128
+ }
1129
+ var enrollmentId = _validateUuid(input.enrollment_id, "enrollment_id");
1130
+ var reason = _validateReason(input.reason);
1131
+ var now = input.cancelled_at == null ? _now() : input.cancelled_at;
1132
+ _validateNonNegInt(now, "cancelled_at");
1133
+
1134
+ var enr = await _getEnrollmentRow(enrollmentId);
1135
+ if (!enr) {
1136
+ throw new TypeError(
1137
+ "winbackCampaigns.cancelEnrollment: enrollment '" + enrollmentId + "' not found"
1138
+ );
1139
+ }
1140
+ if (enr.status !== "active") {
1141
+ return {
1142
+ enrollment_id: enrollmentId,
1143
+ changed: false,
1144
+ status: enr.status,
1145
+ };
1146
+ }
1147
+ await query(
1148
+ "UPDATE winback_enrollments SET " +
1149
+ "status = 'cancelled', next_step_at = NULL, " +
1150
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
1151
+ "WHERE id = ?3 AND status = 'active'",
1152
+ [now, reason, enrollmentId],
1153
+ );
1154
+ return {
1155
+ enrollment_id: enrollmentId,
1156
+ changed: true,
1157
+ status: "cancelled",
1158
+ };
1159
+ },
1160
+
1161
+ // Read a single campaign.
1162
+ getCampaign: async function (slug) {
1163
+ _validateSlug(slug, "slug");
1164
+ return _rowToCampaign(await _getCampaignRow(slug));
1165
+ },
1166
+
1167
+ // Read a single enrollment.
1168
+ getEnrollment: async function (enrollmentId) {
1169
+ var id = _validateUuid(enrollmentId, "enrollment_id");
1170
+ return _rowToEnrollment(await _getEnrollmentRow(id));
1171
+ },
1172
+
1173
+ // Read the delivery log for an enrollment, oldest-first.
1174
+ deliveriesForEnrollment: async function (enrollmentId) {
1175
+ var id = _validateUuid(enrollmentId, "enrollment_id");
1176
+ var rows = (await query(
1177
+ "SELECT * FROM winback_deliveries " +
1178
+ "WHERE enrollment_id = ?1 ORDER BY delivered_at ASC, step_index ASC",
1179
+ [id],
1180
+ )).rows;
1181
+ var out = [];
1182
+ for (var i = 0; i < rows.length; i += 1) out.push(_rowToDelivery(rows[i]));
1183
+ return out;
1184
+ },
1185
+
1186
+ // Aggregate metrics for a campaign over a `[from, to]` window
1187
+ // (inclusive). Returns:
1188
+ // * total enrollments created in the window
1189
+ // * per-status counts
1190
+ // * recovery_rate = recovered / total (0 when total is 0)
1191
+ // * avg_time_to_purchase_ms across recovered enrollments
1192
+ // (recovered_at - created_at; 0 when no recoveries)
1193
+ // * per-step delivery counts (step_index → count) for
1194
+ // deliveries that landed in the window
1195
+ metricsForCampaign: async function (input) {
1196
+ if (!input || typeof input !== "object") {
1197
+ throw new TypeError("winbackCampaigns.metricsForCampaign: input object required");
1198
+ }
1199
+ var slug = _validateSlug(input.slug, "slug");
1200
+ _validateNonNegInt(input.from, "from");
1201
+ _validateNonNegInt(input.to, "to");
1202
+ if (input.to < input.from) {
1203
+ throw new TypeError(
1204
+ "winbackCampaigns.metricsForCampaign: to (" + input.to +
1205
+ ") must be >= from (" + input.from + ")"
1206
+ );
1207
+ }
1208
+ var camp = await _getCampaignRow(slug);
1209
+ if (!camp) {
1210
+ throw new TypeError(
1211
+ "winbackCampaigns.metricsForCampaign: campaign '" + slug + "' not found"
1212
+ );
1213
+ }
1214
+
1215
+ var statusRows = (await query(
1216
+ "SELECT status, COUNT(*) AS n FROM winback_enrollments " +
1217
+ "WHERE campaign_slug = ?1 AND created_at >= ?2 AND created_at <= ?3 " +
1218
+ "GROUP BY status",
1219
+ [slug, input.from, input.to],
1220
+ )).rows;
1221
+ var counts = {
1222
+ active: 0,
1223
+ recovered: 0,
1224
+ exhausted: 0,
1225
+ cancelled: 0,
1226
+ };
1227
+ var total = 0;
1228
+ for (var i = 0; i < statusRows.length; i += 1) {
1229
+ var n = Number(statusRows[i].n || 0);
1230
+ if (Object.prototype.hasOwnProperty.call(counts, statusRows[i].status)) {
1231
+ counts[statusRows[i].status] = n;
1232
+ }
1233
+ total += n;
1234
+ }
1235
+
1236
+ var avgRow = (await query(
1237
+ "SELECT AVG(recovered_at - created_at) AS avg_ms " +
1238
+ "FROM winback_enrollments " +
1239
+ "WHERE campaign_slug = ?1 AND status = 'recovered' " +
1240
+ " AND created_at >= ?2 AND created_at <= ?3",
1241
+ [slug, input.from, input.to],
1242
+ )).rows[0];
1243
+ var avgTimeToPurchase = 0;
1244
+ if (avgRow && avgRow.avg_ms != null) {
1245
+ avgTimeToPurchase = Number(avgRow.avg_ms);
1246
+ }
1247
+
1248
+ var deliveryRows = (await query(
1249
+ "SELECT d.step_index AS step_index, COUNT(*) AS n " +
1250
+ "FROM winback_deliveries d " +
1251
+ "JOIN winback_enrollments e ON e.id = d.enrollment_id " +
1252
+ "WHERE e.campaign_slug = ?1 " +
1253
+ " AND d.delivered_at >= ?2 AND d.delivered_at <= ?3 " +
1254
+ "GROUP BY d.step_index ORDER BY d.step_index ASC",
1255
+ [slug, input.from, input.to],
1256
+ )).rows;
1257
+ var perStep = {};
1258
+ var totalDeliveries = 0;
1259
+ for (var di = 0; di < deliveryRows.length; di += 1) {
1260
+ var dn = Number(deliveryRows[di].n || 0);
1261
+ perStep[String(deliveryRows[di].step_index)] = dn;
1262
+ totalDeliveries += dn;
1263
+ }
1264
+
1265
+ var recoveryRate = total === 0 ? 0 : counts.recovered / total;
1266
+ return {
1267
+ campaign_slug: slug,
1268
+ from: input.from,
1269
+ to: input.to,
1270
+ total: total,
1271
+ counts: counts,
1272
+ recovery_rate: recoveryRate,
1273
+ avg_time_to_purchase_ms: avgTimeToPurchase,
1274
+ per_step_deliveries: perStep,
1275
+ total_deliveries: totalDeliveries,
1276
+ };
1277
+ },
1278
+
1279
+ // Expose the optional deps so a wiring sanity check can assert
1280
+ // they reached the factory.
1281
+ _deps: {
1282
+ order: order,
1283
+ customerSegments: customerSegments,
1284
+ email: email,
1285
+ emailSuppressions: emailSuppressions,
1286
+ coupons: coupons,
1287
+ },
1288
+ };
1289
+ }
1290
+
1291
+ // Async `run()` entry point — composes with the operator's external
1292
+ // db handle + invokes the cron-driven scan+enroll+dispatch loop in a
1293
+ // single call. Operators wire this to a Workers Cron Trigger or a
1294
+ // node-side scheduler; it returns a summary of "what landed in this
1295
+ // tick" so the caller can log + alert.
1296
+ async function run(runOpts) {
1297
+ runOpts = runOpts || {};
1298
+ var query = runOpts.query;
1299
+ var asOf = runOpts.as_of == null ? _now() : runOpts.as_of;
1300
+ var batchSize = runOpts.batch_size == null ? 500 : runOpts.batch_size;
1301
+ var resolveEmail = runOpts.resolveEmail || null;
1302
+ if (!query) {
1303
+ throw new TypeError(
1304
+ "winbackCampaigns.run: opts.query required (D1-compatible query function)"
1305
+ );
1306
+ }
1307
+ _validateNonNegInt(asOf, "as_of");
1308
+ _validatePositiveInt(batchSize, "batch_size");
1309
+
1310
+ var inst = create({
1311
+ query: query,
1312
+ order: runOpts.order || null,
1313
+ customerSegments: runOpts.customerSegments || null,
1314
+ email: runOpts.email || null,
1315
+ emailSuppressions: runOpts.emailSuppressions || null,
1316
+ coupons: runOpts.coupons || null,
1317
+ });
1318
+
1319
+ var candidates = await inst.scanForLapsedCustomers({
1320
+ as_of: asOf,
1321
+ max_batch: batchSize,
1322
+ });
1323
+ var enrolledN = 0;
1324
+ for (var i = 0; i < candidates.length; i += 1) {
1325
+ await inst.enrollCustomer({
1326
+ campaign_slug: candidates[i].campaign_slug,
1327
+ customer_id: candidates[i].customer_id,
1328
+ now: asOf,
1329
+ });
1330
+ enrolledN += 1;
1331
+ }
1332
+ var dispatch = await inst.dispatchTick({
1333
+ now: asOf,
1334
+ batch_size: batchSize,
1335
+ resolveEmail: resolveEmail,
1336
+ });
1337
+ return {
1338
+ as_of: asOf,
1339
+ candidates: candidates.length,
1340
+ enrolled: enrolledN,
1341
+ dispatched: dispatch.dispatched,
1342
+ };
1343
+ }
1344
+
1345
+ module.exports = {
1346
+ create: create,
1347
+ run: run,
1348
+ ENROLLMENT_STATUSES: ENROLLMENT_STATUSES,
1349
+ COUPON_KINDS: COUPON_KINDS,
1350
+ };