@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,1133 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.cartRecovery
4
+ * @title Cart recovery — multi-step abandonment email sequences
5
+ *
6
+ * @intro
7
+ * `cart-abandonment` detects the idle cart + writes a pending
8
+ * detection row. This primitive owns the multi-step nurture that
9
+ * follows: a 1h reminder, then a 24h discount nudge, then a 72h
10
+ * last-chance. Operators define the sequence once, enroll each
11
+ * detection at fan-out time, and run `dispatchTick` on a cron
12
+ * cadence. The tick walks enrollments whose `next_step_at <= now`,
13
+ * renders + sends the step's email (subject to the suppression
14
+ * gate), then advances `current_step_index` + `next_step_at` to
15
+ * the following step. When every step has fired the enrollment
16
+ * lands `completed`; when the customer pays the operator's
17
+ * checkout layer calls `markRecovered` and the enrollment lands
18
+ * `recovered` (terminal).
19
+ *
20
+ * Composition:
21
+ *
22
+ * var recovery = bShop.cartRecovery.create({
23
+ * query: q,
24
+ * cartAbandonment: bShop.cartAbandonment.create({ query: q, cart: cart }),
25
+ * email: bShop.email.create({ mailer: m }),
26
+ * emailSuppressions: bShop.emailSuppressions.create({ query: q }),
27
+ * });
28
+ *
29
+ * await recovery.defineSequence({
30
+ * slug: "default-recovery",
31
+ * title: "Default 3-step recovery",
32
+ * steps: [
33
+ * { step_index: 0, offset_ms: 1 * 3600 * 1000, kind: "reminder" },
34
+ * { step_index: 1, offset_ms: 24 * 3600 * 1000, kind: "discount" },
35
+ * { step_index: 2, offset_ms: 72 * 3600 * 1000, kind: "last_chance" },
36
+ * ],
37
+ * });
38
+ *
39
+ * // After cartAbandonment.scan() returns a detection:
40
+ * await recovery.enrollDetection({
41
+ * detection_id: candidate.detection_id,
42
+ * sequence_slug: "default-recovery",
43
+ * cart_id: candidate.cart_id,
44
+ * customer_id: candidate.customer_id,
45
+ * customer_email: "shopper@example.com",
46
+ * });
47
+ *
48
+ * // Cron-driven dispatcher; safe to call concurrently — the
49
+ * // FSM transition is gated by current_step_index = ?.
50
+ * await recovery.dispatchTick();
51
+ *
52
+ * // Checkout layer signals recovery:
53
+ * await recovery.markRecovered(enrollment_id, {
54
+ * order_id: "<uuid>",
55
+ * });
56
+ *
57
+ * FSM:
58
+ *
59
+ * enrolled --dispatchTick (each step)--> enrolled (next step queued)
60
+ * enrolled --dispatchTick (last step)--> completed
61
+ * enrolled --markRecovered---------------> recovered
62
+ * enrolled --cancel----------------------> cancelled
63
+ *
64
+ * `completed` / `recovered` / `cancelled` are terminal. `recovered`
65
+ * trumps `completed` even when every step has already fired — the
66
+ * `markRecovered` call rewrites the terminal status so
67
+ * `metricsForSequence` counts the order against the sequence's
68
+ * recovery rate.
69
+ *
70
+ * Privacy:
71
+ *
72
+ * `customer_email` reaches `enrollDetection` only at enrollment
73
+ * time; the row stores the namespace-hashed digest via
74
+ * `b.crypto.namespaceHash("cart-recovery-email", normalized)` and
75
+ * never the raw address. The dispatcher re-asks the operator's
76
+ * `email` primitive for the actual send — the operator owns the
77
+ * route from `customer_id` to a deliverable address (typically
78
+ * via the customers primitive).
79
+ *
80
+ * Suppression check:
81
+ *
82
+ * Before each send the dispatcher calls
83
+ * `emailSuppressions.isSuppressed({ email, scope: 'marketing' })`
84
+ * when an `emailSuppressions` dep is wired. A `suppressed=true`
85
+ * result writes a `skipped-suppressed` dispatch row + cancels the
86
+ * enrollment (the customer asked to be left alone; the next step
87
+ * wouldn't be welcome either).
88
+ *
89
+ * Monotonic clock:
90
+ *
91
+ * Two dispatch attempts in the same millisecond would write
92
+ * identical `dispatched_at` values + arrive at the dispatcher in
93
+ * non-deterministic order. The `_now()` helper bumps by 1ms on a
94
+ * tie so the dispatch log stays strictly increasing and a sort-
95
+ * by-timestamp read returns the events in the order they were
96
+ * issued.
97
+ *
98
+ * Storage:
99
+ * - `cart_recovery_sequences` + `cart_recovery_enrollments` +
100
+ * `cart_recovery_dispatches` (migration `0171_cart_recovery.sql`).
101
+ *
102
+ * Composes ONLY blamejs:
103
+ * - `b.uuid.v7` — enrollment + dispatch row ids.
104
+ * - `b.crypto.namespaceHash` — customer_email hashing.
105
+ * - `b.guardUuid` — strict UUID gate on every id at the
106
+ * entry point.
107
+ *
108
+ * @primitive cartRecovery
109
+ * @related shop.cartAbandonment, shop.email, shop.emailSuppressions,
110
+ * b.uuid.v7, b.crypto.namespaceHash, b.guardUuid
111
+ */
112
+
113
+ var bShop;
114
+ function _b() {
115
+ if (!bShop) bShop = require("./index");
116
+ return bShop.framework;
117
+ }
118
+
119
+ // ---- constants ----------------------------------------------------------
120
+
121
+ var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
122
+ var MAX_TITLE_LEN = 200;
123
+ var MAX_REASON_LEN = 280;
124
+ var MAX_STEPS = 12;
125
+
126
+ var EMAIL_NAMESPACE = "cart-recovery-email";
127
+
128
+ var STEP_KINDS = Object.freeze([
129
+ "reminder",
130
+ "discount",
131
+ "last_chance",
132
+ "generic",
133
+ ]);
134
+
135
+ var ENROLLMENT_STATUSES = Object.freeze([
136
+ "enrolled",
137
+ "completed",
138
+ "recovered",
139
+ "cancelled",
140
+ ]);
141
+
142
+ var DISPATCH_STATUSES = Object.freeze([
143
+ "sent",
144
+ "skipped-suppressed",
145
+ "skipped-no-email",
146
+ "failed",
147
+ ]);
148
+
149
+ // ---- monotonic clock ---------------------------------------------------
150
+ //
151
+ // Two dispatch attempts inside the same millisecond would write
152
+ // identical `dispatched_at` columns and the per-enrollment audit
153
+ // panel would render them in non-deterministic order. Bumping by 1ms
154
+ // on a tie keeps the timeline strictly increasing.
155
+
156
+ var _lastTs = 0;
157
+ function _now() {
158
+ var t = Date.now();
159
+ if (t <= _lastTs) { t = _lastTs + 1; }
160
+ _lastTs = t;
161
+ return t;
162
+ }
163
+
164
+ // ---- validators --------------------------------------------------------
165
+
166
+ function _validateSlug(s, label) {
167
+ if (typeof s !== "string" || !s.length) {
168
+ throw new TypeError("cartRecovery: " + label + " must be a non-empty string");
169
+ }
170
+ if (!SLUG_RE.test(s)) {
171
+ throw new TypeError(
172
+ "cartRecovery: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
173
+ );
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _validateTitle(s) {
179
+ if (typeof s !== "string" || !s.length) {
180
+ throw new TypeError("cartRecovery: title must be a non-empty string");
181
+ }
182
+ if (s.length > MAX_TITLE_LEN) {
183
+ throw new TypeError("cartRecovery: title must be <= " + MAX_TITLE_LEN + " characters");
184
+ }
185
+ if (/[\r\n\0]/.test(s)) {
186
+ throw new TypeError("cartRecovery: title must not contain CR / LF / NUL");
187
+ }
188
+ return s;
189
+ }
190
+
191
+ function _validateUuid(s, label) {
192
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
193
+ catch (e) {
194
+ throw new TypeError(
195
+ "cartRecovery: " + label + " — " + (e && e.message || "invalid UUID")
196
+ );
197
+ }
198
+ }
199
+
200
+ function _validatePositiveInt(n, label) {
201
+ if (!Number.isInteger(n) || n <= 0) {
202
+ throw new TypeError("cartRecovery: " + label + " must be a positive integer");
203
+ }
204
+ return n;
205
+ }
206
+
207
+ function _validateNonNegInt(n, label) {
208
+ if (!Number.isInteger(n) || n < 0) {
209
+ throw new TypeError(
210
+ "cartRecovery: " + label + " must be a non-negative integer"
211
+ );
212
+ }
213
+ return n;
214
+ }
215
+
216
+ function _validateStepKind(k) {
217
+ if (typeof k !== "string" || STEP_KINDS.indexOf(k) === -1) {
218
+ throw new TypeError(
219
+ "cartRecovery: step.kind must be one of " + STEP_KINDS.join(", ")
220
+ );
221
+ }
222
+ return k;
223
+ }
224
+
225
+ function _validateReason(s) {
226
+ if (typeof s !== "string" || !s.length) {
227
+ throw new TypeError("cartRecovery: reason must be a non-empty string");
228
+ }
229
+ if (s.length > MAX_REASON_LEN) {
230
+ throw new TypeError(
231
+ "cartRecovery: reason must be <= " + MAX_REASON_LEN + " characters"
232
+ );
233
+ }
234
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
235
+ throw new TypeError("cartRecovery: reason must not contain control bytes");
236
+ }
237
+ return s;
238
+ }
239
+
240
+ // Operator-supplied email is normalized lowercase + trimmed before
241
+ // hashing so 'Shopper@Example.com' and 'shopper@example.com' index
242
+ // to the same digest. The full RFC 5322 grammar is out of scope —
243
+ // the operator's checkout already validated the address; this guard
244
+ // just refuses obvious garbage so a hostile caller can't smuggle a
245
+ // control byte into the hash input.
246
+ function _validateEmail(s) {
247
+ if (typeof s !== "string" || !s.length || s.length > 254) {
248
+ throw new TypeError(
249
+ "cartRecovery: customer_email must be a non-empty string <= 254 characters"
250
+ );
251
+ }
252
+ if (/[\x00-\x1f\x7f\s]/.test(s)) {
253
+ throw new TypeError("cartRecovery: customer_email must not contain whitespace or control bytes");
254
+ }
255
+ if (s.indexOf("@") < 1) {
256
+ throw new TypeError("cartRecovery: customer_email must contain '@'");
257
+ }
258
+ return s.trim().toLowerCase();
259
+ }
260
+
261
+ // Steps array — operator declares an ordered list of
262
+ // `{step_index, offset_ms, kind}`. `step_index` MUST be a dense 0..N-1
263
+ // sequence (no gaps); `offset_ms` MUST be strictly increasing so the
264
+ // dispatcher never has to send two steps in the same tick window.
265
+ function _validateSteps(steps) {
266
+ if (!Array.isArray(steps) || steps.length === 0) {
267
+ throw new TypeError("cartRecovery: steps must be a non-empty array");
268
+ }
269
+ if (steps.length > MAX_STEPS) {
270
+ throw new TypeError(
271
+ "cartRecovery: steps must declare <= " + MAX_STEPS + " entries"
272
+ );
273
+ }
274
+ var out = [];
275
+ var prevOffset = -1;
276
+ for (var i = 0; i < steps.length; i += 1) {
277
+ var s = steps[i];
278
+ if (!s || typeof s !== "object" || Array.isArray(s)) {
279
+ throw new TypeError("cartRecovery: steps[" + i + "] must be an object");
280
+ }
281
+ if (s.step_index !== i) {
282
+ throw new TypeError(
283
+ "cartRecovery: steps[" + i + "].step_index must equal " + i +
284
+ " (dense 0..N-1 sequence required)"
285
+ );
286
+ }
287
+ _validatePositiveInt(s.offset_ms, "steps[" + i + "].offset_ms");
288
+ if (s.offset_ms <= prevOffset) {
289
+ throw new TypeError(
290
+ "cartRecovery: steps[" + i + "].offset_ms (" + s.offset_ms +
291
+ ") must be strictly greater than the prior step's offset (" + prevOffset + ")"
292
+ );
293
+ }
294
+ _validateStepKind(s.kind);
295
+ out.push({
296
+ step_index: i,
297
+ offset_ms: s.offset_ms,
298
+ kind: s.kind,
299
+ });
300
+ prevOffset = s.offset_ms;
301
+ }
302
+ return out;
303
+ }
304
+
305
+ // ---- row → public shape -------------------------------------------------
306
+
307
+ function _rowToSequence(row) {
308
+ if (!row) return null;
309
+ var steps;
310
+ try { steps = JSON.parse(row.steps_json); }
311
+ catch (_e) {
312
+ // drop-silent — a malformed JSON column would be a write-side
313
+ // corruption we surface as the operator-readable empty shape so
314
+ // the dashboard renders rather than crashes.
315
+ steps = [];
316
+ }
317
+ return {
318
+ slug: row.slug,
319
+ title: row.title,
320
+ steps: steps,
321
+ active: Number(row.active) === 1,
322
+ created_at: Number(row.created_at),
323
+ updated_at: Number(row.updated_at),
324
+ };
325
+ }
326
+
327
+ function _rowToEnrollment(row) {
328
+ if (!row) return null;
329
+ return {
330
+ id: row.id,
331
+ sequence_slug: row.sequence_slug,
332
+ detection_id: row.detection_id || null,
333
+ cart_id: row.cart_id,
334
+ customer_id: row.customer_id || null,
335
+ customer_email_hash: row.customer_email_hash || null,
336
+ status: row.status,
337
+ current_step_index: Number(row.current_step_index),
338
+ next_step_at: row.next_step_at == null ? null : Number(row.next_step_at),
339
+ enrolled_at: Number(row.enrolled_at),
340
+ updated_at: Number(row.updated_at),
341
+ recovered_at: row.recovered_at == null ? null : Number(row.recovered_at),
342
+ recovered_order_id: row.recovered_order_id || null,
343
+ cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
344
+ cancelled_reason: row.cancelled_reason || null,
345
+ };
346
+ }
347
+
348
+ function _rowToDispatch(row) {
349
+ if (!row) return null;
350
+ return {
351
+ id: row.id,
352
+ enrollment_id: row.enrollment_id,
353
+ step_index: Number(row.step_index),
354
+ status: row.status,
355
+ reason: row.reason || null,
356
+ dispatched_at: Number(row.dispatched_at),
357
+ };
358
+ }
359
+
360
+ // ---- factory ------------------------------------------------------------
361
+
362
+ function create(opts) {
363
+ opts = opts || {};
364
+
365
+ var query = opts.query;
366
+ if (!query) {
367
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
368
+ }
369
+
370
+ // Optional dep: the cart-abandonment primitive. When wired, the
371
+ // dispatcher can resolve a detection row + bump its terminal
372
+ // reminder_status; absent, enrollments still flow and the operator
373
+ // wires the cart-abandonment bookkeeping at the call site.
374
+ var cartAbandonment = opts.cartAbandonment || null;
375
+ if (cartAbandonment && typeof cartAbandonment !== "object") {
376
+ throw new TypeError(
377
+ "cartRecovery.create: opts.cartAbandonment must be an object exposing the cart-abandonment primitive"
378
+ );
379
+ }
380
+
381
+ // Optional dep: the email primitive. When wired, `dispatchTick`
382
+ // routes each step through the matching `sendAbandonedCartReminder`
383
+ // call; absent, the dispatcher still walks the FSM + writes the
384
+ // dispatch log but skips the actual send (the operator's worker
385
+ // performs the send after reading the enrollment row).
386
+ var email = opts.email || null;
387
+ if (email && typeof email !== "object") {
388
+ throw new TypeError(
389
+ "cartRecovery.create: opts.email must be an object exposing the email primitive"
390
+ );
391
+ }
392
+
393
+ // Optional dep: the email-suppressions primitive. When wired, the
394
+ // dispatcher consults `isSuppressed` before each send + cancels the
395
+ // enrollment on a hit (the customer asked to be left alone).
396
+ var emailSuppressions = opts.emailSuppressions || null;
397
+ if (emailSuppressions && typeof emailSuppressions.isSuppressed !== "function") {
398
+ throw new TypeError(
399
+ "cartRecovery.create: opts.emailSuppressions must expose an isSuppressed(input) method"
400
+ );
401
+ }
402
+
403
+ // ---- internals ------------------------------------------------------
404
+
405
+ async function _getSequenceRow(slug) {
406
+ var r = await query(
407
+ "SELECT * FROM cart_recovery_sequences WHERE slug = ?1 LIMIT 1",
408
+ [slug],
409
+ );
410
+ return r.rows[0] || null;
411
+ }
412
+
413
+ async function _getEnrollmentRow(id) {
414
+ var r = await query(
415
+ "SELECT * FROM cart_recovery_enrollments WHERE id = ?1 LIMIT 1",
416
+ [id],
417
+ );
418
+ return r.rows[0] || null;
419
+ }
420
+
421
+ function _hashEmail(email_) {
422
+ return _b().crypto.namespaceHash(EMAIL_NAMESPACE, email_);
423
+ }
424
+
425
+ // Resolve the next-step offset from the sequence row. Returns
426
+ // `null` when `nextIndex` is past the last step (the enrollment is
427
+ // about to land `completed`).
428
+ function _offsetForStep(steps, nextIndex) {
429
+ if (!Array.isArray(steps) || nextIndex >= steps.length) return null;
430
+ var s = steps[nextIndex];
431
+ return s ? s.offset_ms : null;
432
+ }
433
+
434
+ function _kindForStep(steps, idx) {
435
+ if (!Array.isArray(steps) || idx >= steps.length) return null;
436
+ var s = steps[idx];
437
+ return s ? s.kind : null;
438
+ }
439
+
440
+ // ---- surface --------------------------------------------------------
441
+
442
+ return {
443
+ STEP_KINDS: STEP_KINDS,
444
+ ENROLLMENT_STATUSES: ENROLLMENT_STATUSES,
445
+ DISPATCH_STATUSES: DISPATCH_STATUSES,
446
+
447
+ // Define / redefine an operator-owned sequence. The slug is the
448
+ // primary key; redefining replaces the steps + bumps updated_at.
449
+ // Existing enrollments KEEP the steps they were enrolled under —
450
+ // the steps JSON copies into the enrollment's read path at
451
+ // dispatch time, so changing the sequence rewires only future
452
+ // enrollments. (A future "rewire-existing" call would land here
453
+ // as a separate verb.)
454
+ defineSequence: async function (input) {
455
+ if (!input || typeof input !== "object") {
456
+ throw new TypeError("cartRecovery.defineSequence: input object required");
457
+ }
458
+ var slug = _validateSlug(input.slug, "slug");
459
+ var title = _validateTitle(input.title);
460
+ var steps = _validateSteps(input.steps);
461
+
462
+ var now = input.now == null ? _now() : input.now;
463
+ _validateNonNegInt(now, "now");
464
+
465
+ var existing = await _getSequenceRow(slug);
466
+ if (!existing) {
467
+ await query(
468
+ "INSERT INTO cart_recovery_sequences " +
469
+ "(slug, title, steps_json, active, created_at, updated_at) " +
470
+ "VALUES (?1, ?2, ?3, 1, ?4, ?4)",
471
+ [slug, title, JSON.stringify(steps), now],
472
+ );
473
+ } else {
474
+ await query(
475
+ "UPDATE cart_recovery_sequences SET " +
476
+ "title = ?1, steps_json = ?2, updated_at = ?3 WHERE slug = ?4",
477
+ [title, JSON.stringify(steps), now, slug],
478
+ );
479
+ }
480
+ return _rowToSequence(await _getSequenceRow(slug));
481
+ },
482
+
483
+ // Enroll an abandoned-cart detection into a sequence. Schedules
484
+ // `next_step_at` for the first step (enrolled_at + steps[0].offset_ms).
485
+ // The detection_id stays nullable so an operator can enroll a
486
+ // raw cart without a prior detection row (manual operator action
487
+ // from the dashboard).
488
+ enrollDetection: async function (input) {
489
+ if (!input || typeof input !== "object") {
490
+ throw new TypeError("cartRecovery.enrollDetection: input object required");
491
+ }
492
+ var sequenceSlug = _validateSlug(input.sequence_slug, "sequence_slug");
493
+ var cartId = _validateUuid(input.cart_id, "cart_id");
494
+ var detectionId = input.detection_id == null
495
+ ? null
496
+ : _validateUuid(input.detection_id, "detection_id");
497
+ var customerId = input.customer_id == null
498
+ ? null
499
+ : _validateUuid(input.customer_id, "customer_id");
500
+ var customerEmailHash = null;
501
+ if (input.customer_email != null) {
502
+ var normalized = _validateEmail(input.customer_email);
503
+ customerEmailHash = _hashEmail(normalized);
504
+ }
505
+
506
+ var now = input.now == null ? _now() : input.now;
507
+ _validateNonNegInt(now, "now");
508
+
509
+ var seq = await _getSequenceRow(sequenceSlug);
510
+ if (!seq) {
511
+ throw new TypeError(
512
+ "cartRecovery.enrollDetection: sequence '" + sequenceSlug + "' not found"
513
+ );
514
+ }
515
+ if (Number(seq.active) !== 1) {
516
+ throw new TypeError(
517
+ "cartRecovery.enrollDetection: sequence '" + sequenceSlug + "' is inactive"
518
+ );
519
+ }
520
+ var steps;
521
+ try { steps = JSON.parse(seq.steps_json); }
522
+ catch (_e) {
523
+ throw new TypeError(
524
+ "cartRecovery.enrollDetection: sequence '" + sequenceSlug + "' has malformed steps_json"
525
+ );
526
+ }
527
+ if (!Array.isArray(steps) || steps.length === 0) {
528
+ throw new TypeError(
529
+ "cartRecovery.enrollDetection: sequence '" + sequenceSlug + "' has no steps"
530
+ );
531
+ }
532
+
533
+ var id = _b().uuid.v7();
534
+ var nextStepAt = now + steps[0].offset_ms;
535
+ await query(
536
+ "INSERT INTO cart_recovery_enrollments " +
537
+ "(id, sequence_slug, detection_id, cart_id, customer_id, " +
538
+ " customer_email_hash, status, current_step_index, next_step_at, " +
539
+ " enrolled_at, updated_at) " +
540
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'enrolled', 0, ?7, ?8, ?8)",
541
+ [
542
+ id, sequenceSlug, detectionId, cartId, customerId,
543
+ customerEmailHash, nextStepAt, now,
544
+ ],
545
+ );
546
+ return _rowToEnrollment(await _getEnrollmentRow(id));
547
+ },
548
+
549
+ // Cron-driven dispatcher. Walks every enrollment with
550
+ // `status='enrolled'` and `next_step_at <= now` (or the
551
+ // caller-supplied tick time), dispatches the current step, then
552
+ // advances the FSM. Returns the list of dispatch rows written so
553
+ // the caller's observability sink can fan-out a per-step
554
+ // notification without re-reading the table.
555
+ dispatchTick: async function (tickOpts) {
556
+ tickOpts = tickOpts || {};
557
+ var now = tickOpts.now == null ? _now() : tickOpts.now;
558
+ _validateNonNegInt(now, "now");
559
+ var maxBatch = tickOpts.max_batch == null ? 500 : tickOpts.max_batch;
560
+ _validatePositiveInt(maxBatch, "max_batch");
561
+
562
+ // Optional "resolve a deliverable email from the customer_id"
563
+ // hook. The dispatcher passes the enrollment row at the call
564
+ // site; the caller returns the email string (or null when the
565
+ // customer has no contact on file). Absent the resolver the
566
+ // dispatcher uses the hashed email as a presence signal only —
567
+ // it can't send an email it doesn't have the plaintext for.
568
+ var resolveEmail = tickOpts.resolveEmail || null;
569
+ if (resolveEmail != null && typeof resolveEmail !== "function") {
570
+ throw new TypeError(
571
+ "cartRecovery.dispatchTick: resolveEmail must be a function (enrollment) => Promise<string|null>"
572
+ );
573
+ }
574
+
575
+ var due = (await query(
576
+ "SELECT * FROM cart_recovery_enrollments " +
577
+ "WHERE status = 'enrolled' AND next_step_at IS NOT NULL AND next_step_at <= ?1 " +
578
+ "ORDER BY next_step_at ASC LIMIT ?2",
579
+ [now, maxBatch],
580
+ )).rows;
581
+
582
+ var dispatches = [];
583
+
584
+ for (var i = 0; i < due.length; i += 1) {
585
+ var enr = due[i];
586
+ var seq = await _getSequenceRow(enr.sequence_slug);
587
+ if (!seq) {
588
+ // Sequence vanished out from under us (operator deleted the
589
+ // row mid-tick). Cancel the enrollment so the dispatcher
590
+ // doesn't loop on a row it can't service.
591
+ await query(
592
+ "UPDATE cart_recovery_enrollments SET " +
593
+ "status = 'cancelled', next_step_at = NULL, " +
594
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
595
+ "WHERE id = ?3 AND status = 'enrolled'",
596
+ [now, "sequence-missing", enr.id],
597
+ );
598
+ continue;
599
+ }
600
+ var steps;
601
+ try { steps = JSON.parse(seq.steps_json); }
602
+ catch (_e) { steps = []; }
603
+
604
+ var stepIdx = Number(enr.current_step_index);
605
+ var stepKind = _kindForStep(steps, stepIdx);
606
+ if (stepKind == null) {
607
+ // No step at this index — the enrollment is stale (sequence
608
+ // shrank). Land it `completed` so the dispatcher moves on.
609
+ await query(
610
+ "UPDATE cart_recovery_enrollments SET " +
611
+ "status = 'completed', next_step_at = NULL, updated_at = ?1 " +
612
+ "WHERE id = ?2 AND status = 'enrolled'",
613
+ [now, enr.id],
614
+ );
615
+ continue;
616
+ }
617
+
618
+ var dispatchStatus = "sent";
619
+ var dispatchReason = null;
620
+ var cancelEnrollment = false;
621
+ var sendError = null;
622
+
623
+ // Suppression gate: when wired, ask the suppressions
624
+ // primitive before sending. A `suppressed=true` hit writes a
625
+ // `skipped-suppressed` dispatch row + cancels the
626
+ // enrollment (the customer's preference applies to every
627
+ // future step too).
628
+ var resolvedEmail = null;
629
+ if (resolveEmail) {
630
+ try { resolvedEmail = await resolveEmail(_rowToEnrollment(enr)); }
631
+ catch (e) {
632
+ sendError = e && e.message ? String(e.message) : String(e || "resolveEmail failed");
633
+ dispatchStatus = "failed";
634
+ }
635
+ }
636
+
637
+ if (dispatchStatus === "sent" && emailSuppressions && resolvedEmail) {
638
+ try {
639
+ var sup = await emailSuppressions.isSuppressed({
640
+ email: resolvedEmail,
641
+ scope: "marketing",
642
+ });
643
+ if (sup && sup.suppressed) {
644
+ dispatchStatus = "skipped-suppressed";
645
+ dispatchReason = sup.suppression_type || "suppressed";
646
+ cancelEnrollment = true;
647
+ }
648
+ } catch (_e) {
649
+ // drop-silent — a suppressions outage shouldn't block the
650
+ // operator's recovery campaign. The dispatcher errs toward
651
+ // sending; the operator's mailer is the next gate.
652
+ }
653
+ }
654
+
655
+ // No deliverable address — `customer_email` wasn't supplied
656
+ // at enrollment + the operator didn't wire a resolver, OR
657
+ // the resolver returned null. Write a `skipped-no-email` row
658
+ // + cancel the enrollment so the dispatcher doesn't loop.
659
+ if (
660
+ dispatchStatus === "sent" &&
661
+ !resolvedEmail &&
662
+ !enr.customer_email_hash
663
+ ) {
664
+ dispatchStatus = "skipped-no-email";
665
+ dispatchReason = "no-email";
666
+ cancelEnrollment = true;
667
+ }
668
+
669
+ // Actual send. When `email` is wired + we have an address +
670
+ // the gate didn't fire, route through the matching template.
671
+ // The dispatcher leans on `sendAbandonedCartReminder` for
672
+ // every step kind — the email primitive owns subject /
673
+ // template variation per kind (a future refinement could
674
+ // route discount + last_chance through separate verbs).
675
+ if (
676
+ dispatchStatus === "sent" &&
677
+ email &&
678
+ resolvedEmail &&
679
+ typeof email.sendAbandonedCartReminder === "function"
680
+ ) {
681
+ try {
682
+ await email.sendAbandonedCartReminder({
683
+ customer_email: resolvedEmail,
684
+ cart_url: tickOpts.cart_url_base
685
+ ? (tickOpts.cart_url_base + "/" + enr.cart_id)
686
+ : ("https://example.invalid/cart/" + enr.cart_id),
687
+ lines: tickOpts.lines || [
688
+ { title: "Your cart" },
689
+ ],
690
+ });
691
+ } catch (e) {
692
+ sendError = e && e.message ? String(e.message) : String(e || "send failed");
693
+ dispatchStatus = "failed";
694
+ }
695
+ }
696
+
697
+ // Write the dispatch row.
698
+ var dispatchId = _b().uuid.v7();
699
+ var truncatedReason = dispatchReason;
700
+ if (dispatchStatus === "failed" && sendError) {
701
+ truncatedReason = sendError.length > MAX_REASON_LEN
702
+ ? sendError.slice(0, MAX_REASON_LEN)
703
+ : sendError;
704
+ }
705
+ await query(
706
+ "INSERT INTO cart_recovery_dispatches " +
707
+ "(id, enrollment_id, step_index, status, reason, dispatched_at) " +
708
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
709
+ [dispatchId, enr.id, stepIdx, dispatchStatus, truncatedReason, now],
710
+ );
711
+
712
+ // Advance the FSM. On a clean send → bump to the next step
713
+ // (or land `completed` when no next step exists). On
714
+ // suppression / no-email → cancel. On a transient `failed` →
715
+ // keep `enrolled` + push next_step_at out one hour so the
716
+ // next tick retries (the operator's retry policy could shape
717
+ // this further; the default keeps the row alive without
718
+ // hammering the failing send).
719
+ if (cancelEnrollment) {
720
+ await query(
721
+ "UPDATE cart_recovery_enrollments SET " +
722
+ "status = 'cancelled', next_step_at = NULL, " +
723
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
724
+ "WHERE id = ?3 AND status = 'enrolled'",
725
+ [now, dispatchReason || "cancelled-on-dispatch", enr.id],
726
+ );
727
+ } else if (dispatchStatus === "failed") {
728
+ // Push next_step_at one hour out so retry doesn't fire in
729
+ // the same tick. The current_step_index does NOT advance —
730
+ // we're retrying this step.
731
+ await query(
732
+ "UPDATE cart_recovery_enrollments SET " +
733
+ "next_step_at = ?1, updated_at = ?2 " +
734
+ "WHERE id = ?3 AND status = 'enrolled'",
735
+ [now + 3600 * 1000, now, enr.id],
736
+ );
737
+ } else {
738
+ // Clean send. Advance.
739
+ var nextIdx = stepIdx + 1;
740
+ var nextOffset = _offsetForStep(steps, nextIdx);
741
+ if (nextOffset == null) {
742
+ // Last step fired. Land `completed`.
743
+ await query(
744
+ "UPDATE cart_recovery_enrollments SET " +
745
+ "status = 'completed', current_step_index = ?1, " +
746
+ "next_step_at = NULL, updated_at = ?2 " +
747
+ "WHERE id = ?3 AND status = 'enrolled' AND current_step_index = ?4",
748
+ [nextIdx, now, enr.id, stepIdx],
749
+ );
750
+ } else {
751
+ // Schedule the next step relative to the original
752
+ // enrolled_at. Using enrolled_at + offset (rather than
753
+ // now + delta-between-steps) keeps the cadence pinned to
754
+ // the original abandonment moment even when a slow tick
755
+ // delays an earlier send.
756
+ var enrolledAt = Number(enr.enrolled_at);
757
+ await query(
758
+ "UPDATE cart_recovery_enrollments SET " +
759
+ "current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
760
+ "WHERE id = ?4 AND status = 'enrolled' AND current_step_index = ?5",
761
+ [nextIdx, enrolledAt + nextOffset, now, enr.id, stepIdx],
762
+ );
763
+ }
764
+ }
765
+
766
+ dispatches.push({
767
+ enrollment_id: enr.id,
768
+ step_index: stepIdx,
769
+ status: dispatchStatus,
770
+ reason: truncatedReason,
771
+ dispatched_at: now,
772
+ });
773
+ }
774
+
775
+ return {
776
+ dispatched: dispatches.length,
777
+ rows: dispatches,
778
+ };
779
+ },
780
+
781
+ // Direct-record path for callers that drive the send themselves
782
+ // and just want the dispatch log + FSM advance. Idempotent at
783
+ // the (enrollment_id, step_index) pair — re-recording the same
784
+ // step is a no-op.
785
+ recordStepSent: async function (enrollmentId, recOpts) {
786
+ var id = _validateUuid(enrollmentId, "enrollment_id");
787
+ recOpts = recOpts || {};
788
+ _validateNonNegInt(recOpts.step_index, "step_index");
789
+ var now = recOpts.sent_at == null ? _now() : recOpts.sent_at;
790
+ _validateNonNegInt(now, "sent_at");
791
+
792
+ var enr = await _getEnrollmentRow(id);
793
+ if (!enr) {
794
+ throw new TypeError(
795
+ "cartRecovery.recordStepSent: enrollment '" + id + "' not found"
796
+ );
797
+ }
798
+ if (enr.status !== "enrolled") {
799
+ return {
800
+ enrollment_id: id,
801
+ changed: false,
802
+ status: enr.status,
803
+ };
804
+ }
805
+ if (Number(enr.current_step_index) !== recOpts.step_index) {
806
+ throw new TypeError(
807
+ "cartRecovery.recordStepSent: step_index " + recOpts.step_index +
808
+ " does not match enrollment's current_step_index " + enr.current_step_index
809
+ );
810
+ }
811
+
812
+ var seq = await _getSequenceRow(enr.sequence_slug);
813
+ if (!seq) {
814
+ throw new TypeError(
815
+ "cartRecovery.recordStepSent: sequence '" + enr.sequence_slug + "' not found"
816
+ );
817
+ }
818
+ var steps;
819
+ try { steps = JSON.parse(seq.steps_json); }
820
+ catch (_e) { steps = []; }
821
+
822
+ // Idempotency: if a dispatch row already exists for this
823
+ // (enrollment, step_index) pair we don't write a second one.
824
+ var prior = await query(
825
+ "SELECT id FROM cart_recovery_dispatches " +
826
+ "WHERE enrollment_id = ?1 AND step_index = ?2 LIMIT 1",
827
+ [id, recOpts.step_index],
828
+ );
829
+ if (!prior.rows[0]) {
830
+ var dispatchId = _b().uuid.v7();
831
+ await query(
832
+ "INSERT INTO cart_recovery_dispatches " +
833
+ "(id, enrollment_id, step_index, status, reason, dispatched_at) " +
834
+ "VALUES (?1, ?2, ?3, 'sent', NULL, ?4)",
835
+ [dispatchId, id, recOpts.step_index, now],
836
+ );
837
+ }
838
+
839
+ var nextIdx = recOpts.step_index + 1;
840
+ var nextOffset = _offsetForStep(steps, nextIdx);
841
+ var enrolledAt = Number(enr.enrolled_at);
842
+ if (nextOffset == null) {
843
+ await query(
844
+ "UPDATE cart_recovery_enrollments SET " +
845
+ "status = 'completed', current_step_index = ?1, " +
846
+ "next_step_at = NULL, updated_at = ?2 " +
847
+ "WHERE id = ?3 AND status = 'enrolled'",
848
+ [nextIdx, now, id],
849
+ );
850
+ } else {
851
+ await query(
852
+ "UPDATE cart_recovery_enrollments SET " +
853
+ "current_step_index = ?1, next_step_at = ?2, updated_at = ?3 " +
854
+ "WHERE id = ?4 AND status = 'enrolled'",
855
+ [nextIdx, enrolledAt + nextOffset, now, id],
856
+ );
857
+ }
858
+
859
+ return {
860
+ enrollment_id: id,
861
+ changed: true,
862
+ next_step_index: nextIdx,
863
+ };
864
+ },
865
+
866
+ // Operator marks a step skipped (suppression / opt-out / bounce
867
+ // landing post-send). Writes the dispatch row + cancels the
868
+ // enrollment (the customer's preference applies to every future
869
+ // step too).
870
+ markStepSkipped: async function (enrollmentId, skipOpts) {
871
+ var id = _validateUuid(enrollmentId, "enrollment_id");
872
+ if (!skipOpts || typeof skipOpts !== "object") {
873
+ throw new TypeError("cartRecovery.markStepSkipped: opts object required");
874
+ }
875
+ _validateNonNegInt(skipOpts.step_index, "step_index");
876
+ _validateReason(skipOpts.reason);
877
+ var now = skipOpts.skipped_at == null ? _now() : skipOpts.skipped_at;
878
+ _validateNonNegInt(now, "skipped_at");
879
+
880
+ var enr = await _getEnrollmentRow(id);
881
+ if (!enr) {
882
+ throw new TypeError(
883
+ "cartRecovery.markStepSkipped: enrollment '" + id + "' not found"
884
+ );
885
+ }
886
+ if (enr.status !== "enrolled") {
887
+ return {
888
+ enrollment_id: id,
889
+ changed: false,
890
+ status: enr.status,
891
+ };
892
+ }
893
+
894
+ var dispatchId = _b().uuid.v7();
895
+ await query(
896
+ "INSERT INTO cart_recovery_dispatches " +
897
+ "(id, enrollment_id, step_index, status, reason, dispatched_at) " +
898
+ "VALUES (?1, ?2, ?3, 'skipped-suppressed', ?4, ?5)",
899
+ [dispatchId, id, skipOpts.step_index, skipOpts.reason, now],
900
+ );
901
+ await query(
902
+ "UPDATE cart_recovery_enrollments SET " +
903
+ "status = 'cancelled', next_step_at = NULL, " +
904
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
905
+ "WHERE id = ?3 AND status = 'enrolled'",
906
+ [now, skipOpts.reason, id],
907
+ );
908
+
909
+ return {
910
+ enrollment_id: id,
911
+ changed: true,
912
+ status: "cancelled",
913
+ };
914
+ },
915
+
916
+ // Checkout layer signals "customer paid for the abandoned cart."
917
+ // The enrollment lands `recovered` (terminal). Rewrites a prior
918
+ // `completed` row too — the order trumps the calendar, since the
919
+ // operator's metrics treat recovery as the success outcome
920
+ // regardless of whether every step had already fired.
921
+ markRecovered: async function (enrollmentId, recOpts) {
922
+ var id = _validateUuid(enrollmentId, "enrollment_id");
923
+ recOpts = recOpts || {};
924
+ var orderId = recOpts.order_id == null
925
+ ? null
926
+ : _validateUuid(recOpts.order_id, "order_id");
927
+ var now = recOpts.recovered_at == null ? _now() : recOpts.recovered_at;
928
+ _validateNonNegInt(now, "recovered_at");
929
+
930
+ var enr = await _getEnrollmentRow(id);
931
+ if (!enr) {
932
+ throw new TypeError(
933
+ "cartRecovery.markRecovered: enrollment '" + id + "' not found"
934
+ );
935
+ }
936
+ if (enr.status === "recovered") {
937
+ // Idempotent — keep the first recovered_at + order_id.
938
+ return {
939
+ enrollment_id: id,
940
+ changed: false,
941
+ status: "recovered",
942
+ };
943
+ }
944
+ if (enr.status === "cancelled") {
945
+ throw new TypeError(
946
+ "cartRecovery.markRecovered: enrollment '" + id +
947
+ "' is cancelled — cannot transition to recovered"
948
+ );
949
+ }
950
+
951
+ await query(
952
+ "UPDATE cart_recovery_enrollments SET " +
953
+ "status = 'recovered', next_step_at = NULL, " +
954
+ "recovered_at = ?1, recovered_order_id = ?2, updated_at = ?1 " +
955
+ "WHERE id = ?3 AND status IN ('enrolled', 'completed')",
956
+ [now, orderId, id],
957
+ );
958
+
959
+ return {
960
+ enrollment_id: id,
961
+ changed: true,
962
+ status: "recovered",
963
+ order_id: orderId,
964
+ };
965
+ },
966
+
967
+ // Operator manual cancel — pulls the enrollment out of the
968
+ // dispatch queue without a send. Distinct from `markStepSkipped`
969
+ // (which records a dispatch row); this verb is the operator-
970
+ // dashboard "stop nurturing this customer" button.
971
+ cancelEnrollment: async function (enrollmentId, cancelOpts) {
972
+ var id = _validateUuid(enrollmentId, "enrollment_id");
973
+ if (!cancelOpts || typeof cancelOpts !== "object") {
974
+ throw new TypeError("cartRecovery.cancelEnrollment: opts object required");
975
+ }
976
+ var reason = _validateReason(cancelOpts.reason);
977
+ var now = cancelOpts.cancelled_at == null ? _now() : cancelOpts.cancelled_at;
978
+ _validateNonNegInt(now, "cancelled_at");
979
+
980
+ var enr = await _getEnrollmentRow(id);
981
+ if (!enr) {
982
+ throw new TypeError(
983
+ "cartRecovery.cancelEnrollment: enrollment '" + id + "' not found"
984
+ );
985
+ }
986
+ if (enr.status !== "enrolled") {
987
+ return {
988
+ enrollment_id: id,
989
+ changed: false,
990
+ status: enr.status,
991
+ };
992
+ }
993
+ await query(
994
+ "UPDATE cart_recovery_enrollments SET " +
995
+ "status = 'cancelled', next_step_at = NULL, " +
996
+ "cancelled_at = ?1, cancelled_reason = ?2, updated_at = ?1 " +
997
+ "WHERE id = ?3 AND status = 'enrolled'",
998
+ [now, reason, id],
999
+ );
1000
+ return {
1001
+ enrollment_id: id,
1002
+ changed: true,
1003
+ status: "cancelled",
1004
+ };
1005
+ },
1006
+
1007
+ // Read a single sequence.
1008
+ getSequence: async function (slug) {
1009
+ _validateSlug(slug, "slug");
1010
+ return _rowToSequence(await _getSequenceRow(slug));
1011
+ },
1012
+
1013
+ // Read a single enrollment.
1014
+ getEnrollment: async function (enrollmentId) {
1015
+ var id = _validateUuid(enrollmentId, "enrollment_id");
1016
+ return _rowToEnrollment(await _getEnrollmentRow(id));
1017
+ },
1018
+
1019
+ // Read the dispatch log for an enrollment, oldest-first so the
1020
+ // dashboard's per-step audit panel renders the timeline in
1021
+ // forward order.
1022
+ dispatchesForEnrollment: async function (enrollmentId) {
1023
+ var id = _validateUuid(enrollmentId, "enrollment_id");
1024
+ var rows = (await query(
1025
+ "SELECT * FROM cart_recovery_dispatches " +
1026
+ "WHERE enrollment_id = ?1 ORDER BY dispatched_at ASC, step_index ASC",
1027
+ [id],
1028
+ )).rows;
1029
+ var out = [];
1030
+ for (var i = 0; i < rows.length; i += 1) out.push(_rowToDispatch(rows[i]));
1031
+ return out;
1032
+ },
1033
+
1034
+ // Every enrollment for a given customer_id, newest-first. The
1035
+ // operator's customer-detail dashboard renders this so the agent
1036
+ // can see "how many recovery sequences this person has been
1037
+ // through" before manually intervening.
1038
+ enrollmentsForCustomer: async function (customerId) {
1039
+ var id = _validateUuid(customerId, "customer_id");
1040
+ var rows = (await query(
1041
+ "SELECT * FROM cart_recovery_enrollments " +
1042
+ "WHERE customer_id = ?1 ORDER BY enrolled_at DESC, id DESC",
1043
+ [id],
1044
+ )).rows;
1045
+ var out = [];
1046
+ for (var i = 0; i < rows.length; i += 1) out.push(_rowToEnrollment(rows[i]));
1047
+ return out;
1048
+ },
1049
+
1050
+ // Aggregate metrics for a sequence. Returns enrollments by
1051
+ // status + the derived recovery rate (recovered / total). The
1052
+ // recovery rate is the operator's headline number — what
1053
+ // fraction of nudged carts actually paid.
1054
+ metricsForSequence: async function (sequenceSlug) {
1055
+ var slug = _validateSlug(sequenceSlug, "sequence_slug");
1056
+ var seq = await _getSequenceRow(slug);
1057
+ if (!seq) {
1058
+ throw new TypeError(
1059
+ "cartRecovery.metricsForSequence: sequence '" + slug + "' not found"
1060
+ );
1061
+ }
1062
+ var rows = (await query(
1063
+ "SELECT status, COUNT(*) AS n FROM cart_recovery_enrollments " +
1064
+ "WHERE sequence_slug = ?1 GROUP BY status",
1065
+ [slug],
1066
+ )).rows;
1067
+ var counts = {
1068
+ enrolled: 0,
1069
+ completed: 0,
1070
+ recovered: 0,
1071
+ cancelled: 0,
1072
+ };
1073
+ var total = 0;
1074
+ for (var i = 0; i < rows.length; i += 1) {
1075
+ var n = Number(rows[i].n || 0);
1076
+ if (Object.prototype.hasOwnProperty.call(counts, rows[i].status)) {
1077
+ counts[rows[i].status] = n;
1078
+ }
1079
+ total += n;
1080
+ }
1081
+ var dispatchRows = (await query(
1082
+ "SELECT d.status AS status, COUNT(*) AS n " +
1083
+ "FROM cart_recovery_dispatches d " +
1084
+ "JOIN cart_recovery_enrollments e ON e.id = d.enrollment_id " +
1085
+ "WHERE e.sequence_slug = ?1 " +
1086
+ "GROUP BY d.status",
1087
+ [slug],
1088
+ )).rows;
1089
+ var dispatchCounts = {
1090
+ sent: 0,
1091
+ "skipped-suppressed": 0,
1092
+ "skipped-no-email": 0,
1093
+ failed: 0,
1094
+ };
1095
+ var totalDispatches = 0;
1096
+ for (var di = 0; di < dispatchRows.length; di += 1) {
1097
+ var dn = Number(dispatchRows[di].n || 0);
1098
+ if (Object.prototype.hasOwnProperty.call(dispatchCounts, dispatchRows[di].status)) {
1099
+ dispatchCounts[dispatchRows[di].status] = dn;
1100
+ }
1101
+ totalDispatches += dn;
1102
+ }
1103
+ // Recovery rate uses (enrolled + completed + recovered +
1104
+ // cancelled) as the denominator — every enrollment that ever
1105
+ // landed in the sequence. A zero-total sequence returns 0 so
1106
+ // the dashboard can render without a NaN guard.
1107
+ var recoveryRate = total === 0 ? 0 : counts.recovered / total;
1108
+ return {
1109
+ sequence_slug: slug,
1110
+ total: total,
1111
+ counts: counts,
1112
+ dispatches: dispatchCounts,
1113
+ total_dispatches: totalDispatches,
1114
+ recovery_rate: recoveryRate,
1115
+ };
1116
+ },
1117
+
1118
+ // Expose the optional deps so a wiring sanity check can assert
1119
+ // they reached the factory.
1120
+ _deps: {
1121
+ cartAbandonment: cartAbandonment,
1122
+ email: email,
1123
+ emailSuppressions: emailSuppressions,
1124
+ },
1125
+ };
1126
+ }
1127
+
1128
+ module.exports = {
1129
+ create: create,
1130
+ STEP_KINDS: STEP_KINDS,
1131
+ ENROLLMENT_STATUSES: ENROLLMENT_STATUSES,
1132
+ DISPATCH_STATUSES: DISPATCH_STATUSES,
1133
+ };