@blamejs/blamejs-shop 0.0.54 → 0.0.56

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.
@@ -0,0 +1,664 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.cartAbandonment
4
+ * @title Cart-abandonment scanner — detect idle carts, schedule reminder fan-out
5
+ *
6
+ * @intro
7
+ * Operators wire this primitive to a scheduler (Cron Trigger, CF
8
+ * Workers Cron, a host-level cron, etc.) and call `.scan(...)` on
9
+ * a recurring interval. Every invocation:
10
+ *
11
+ * 1. Opens a `cart_abandonment_runs` row (`status='running'`)
12
+ * so a dashboard can see "the scanner is alive."
13
+ * 2. Walks `carts` with `status='active'` and `updated_at <
14
+ * now - idle_threshold_ms` (default 24h), oldest first,
15
+ * capped at `max_carts` (default 500).
16
+ * 3. For each cart with at least one line, writes a
17
+ * `cart_abandonment_detections` row keyed `(id, cart_id,
18
+ * detected_at)` and tagged `reminder_status='pending'`.
19
+ * Idempotent: a cart already detected within the current
20
+ * idle window is skipped (the `idx_cart_abandonment_detections_cart`
21
+ * index makes the "last detection for this cart" SELECT cheap).
22
+ * 4. Returns the list of candidates so a separate worker can
23
+ * fan out reminders without re-scanning. The worker calls
24
+ * `markReminderSent` / `markReminderSkipped` /
25
+ * `markReminderFailed` to transition the row.
26
+ * 5. Closes the run row with the final counts.
27
+ *
28
+ * Privacy posture: `session_id` reaches this primitive only when
29
+ * the scanner is called with a synthetic cart shape; the canonical
30
+ * path reads `session_id` off the `carts` row and hashes it
31
+ * through `b.crypto.namespaceHash` before write. The detection
32
+ * table never stores a raw session id.
33
+ *
34
+ * `customer_id` is the internal UUID. Customer-email lookup
35
+ * happens at fan-out time (the worker resolves the customer's
36
+ * email through the operator's email-delivery layer); the
37
+ * primitive's job is just to mark the candidate row.
38
+ *
39
+ * Status FSM for `reminder_status`:
40
+ * pending — fresh detection, fan-out has not run
41
+ * sent — reminder dispatched
42
+ * skipped-no-email — anonymous cart or customer without contact
43
+ * skipped-suppressed — recipient on the suppression list
44
+ * failed — delivery error
45
+ *
46
+ * Cleanup: `cleanupOld({ before_ts })` deletes detection rows
47
+ * older than ts. Operators run this on a slower cadence (weekly /
48
+ * monthly) so the table doesn't grow unbounded.
49
+ */
50
+
51
+ var bShop;
52
+ function _b() {
53
+ if (!bShop) bShop = require("./index");
54
+ return bShop.framework;
55
+ }
56
+
57
+ // ---- constants ----------------------------------------------------------
58
+
59
+ var STATUSES = Object.freeze([
60
+ "pending",
61
+ "sent",
62
+ "skipped-no-email",
63
+ "skipped-suppressed",
64
+ "failed",
65
+ ]);
66
+
67
+ var SKIP_REASONS = Object.freeze([
68
+ "no-email",
69
+ "suppressed",
70
+ "anonymous",
71
+ "opted-out",
72
+ ]);
73
+
74
+ var DEFAULT_IDLE_THRESHOLD_MS = 24 * 60 * 60 * 1000; // 24h
75
+ var DEFAULT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d
76
+ var DEFAULT_MAX_CARTS = 500;
77
+ var MAX_MAX_CARTS = 5000;
78
+
79
+ var DEFAULT_LIST_LIMIT = 50;
80
+ var MAX_LIST_LIMIT = 200;
81
+
82
+ var SESSION_NAMESPACE = "cart-abandonment-session";
83
+
84
+ var RECENT_ORDER_KEY = ["detected_at:desc", "id:desc"];
85
+
86
+ // ---- validators ---------------------------------------------------------
87
+
88
+ function _uuid(s, label) {
89
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
90
+ catch (e) {
91
+ throw new TypeError(
92
+ "cart-abandonment: " + label + " — " + (e && e.message || "invalid UUID")
93
+ );
94
+ }
95
+ }
96
+
97
+ function _positiveInt(n, label) {
98
+ if (!Number.isInteger(n) || n <= 0) {
99
+ throw new TypeError(
100
+ "cart-abandonment: " + label + " must be a positive integer"
101
+ );
102
+ }
103
+ }
104
+
105
+ function _nonNegInt(n, label) {
106
+ if (!Number.isInteger(n) || n < 0) {
107
+ throw new TypeError(
108
+ "cart-abandonment: " + label + " must be a non-negative integer"
109
+ );
110
+ }
111
+ }
112
+
113
+ function _epochMs(n, label) {
114
+ if (!Number.isInteger(n) || n < 0) {
115
+ throw new TypeError(
116
+ "cart-abandonment: " + label + " must be a non-negative integer (epoch ms)"
117
+ );
118
+ }
119
+ }
120
+
121
+ function _statusFilter(s) {
122
+ if (STATUSES.indexOf(s) === -1) {
123
+ throw new TypeError(
124
+ "cart-abandonment: status must be one of " + STATUSES.join(", ")
125
+ );
126
+ }
127
+ }
128
+
129
+ function _limit(n, label) {
130
+ if (n == null) return DEFAULT_LIST_LIMIT;
131
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
132
+ throw new TypeError(
133
+ "cart-abandonment: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]"
134
+ );
135
+ }
136
+ return n;
137
+ }
138
+
139
+ function _skipReason(r) {
140
+ if (typeof r !== "string" || SKIP_REASONS.indexOf(r) === -1) {
141
+ throw new TypeError(
142
+ "cart-abandonment: reason must be one of " + SKIP_REASONS.join(", ")
143
+ );
144
+ }
145
+ return r;
146
+ }
147
+
148
+ function _now() { return Date.now(); }
149
+
150
+ // ---- factory ------------------------------------------------------------
151
+
152
+ function create(opts) {
153
+ opts = opts || {};
154
+
155
+ var query = opts.query;
156
+ if (!query) {
157
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
158
+ }
159
+
160
+ // Required dep: the cart primitive. The scanner reads cart rows +
161
+ // line counts through the same surface storefront / checkout use,
162
+ // so a future schema change to `carts` / `cart_lines` flows through
163
+ // one place. Refuse at factory time rather than at first call so a
164
+ // mis-wired deployment fails at boot.
165
+ if (!opts.cart || typeof opts.cart !== "object") {
166
+ throw new TypeError(
167
+ "cart-abandonment.create: opts.cart (cart primitive) is required"
168
+ );
169
+ }
170
+ var cart = opts.cart;
171
+
172
+ // Optional dep: the customers primitive. Reserved for callers that
173
+ // want the scanner to attach the customer's display name to the
174
+ // candidate payload. Today the primitive just passes `customer_id`
175
+ // through to the fan-out worker; the customers dep stays optional
176
+ // and shape-validated for the future hook.
177
+ var customers = opts.customers || null;
178
+ if (customers && typeof customers !== "object") {
179
+ throw new TypeError(
180
+ "cart-abandonment.create: opts.customers must be an object exposing the customers primitive"
181
+ );
182
+ }
183
+
184
+ // Optional dep: the email-suppressions primitive. Reserved for the
185
+ // forthcoming suppression-list landing. Today the fan-out worker
186
+ // performs the suppression check (because it owns the
187
+ // hash-the-email step); the dep stays optional and shape-validated
188
+ // here so a future scanner-side prefilter can compose with it.
189
+ var emailSuppressions = opts.emailSuppressions || null;
190
+ if (emailSuppressions && typeof emailSuppressions.isSuppressed !== "function") {
191
+ throw new TypeError(
192
+ "cart-abandonment.create: opts.emailSuppressions must expose an isSuppressed(email_hash) method"
193
+ );
194
+ }
195
+
196
+ // Pagination cursor secret — same HMAC-tagged shape the rest of
197
+ // the shop primitives use so an operator can't hand-craft one to
198
+ // skip past a hidden detection. Falls back to a dev-only
199
+ // placeholder so the primitive boots in tests; production deploys
200
+ // pass a derived value.
201
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
202
+ if (process.env.NODE_ENV === "production") {
203
+ throw new Error(
204
+ "cart-abandonment.create: opts.cursorSecret is required in production"
205
+ );
206
+ }
207
+ opts.cursorSecret = "cart-abandonment-cursor-secret-dev-only";
208
+ }
209
+ var cursorSecret = opts.cursorSecret;
210
+
211
+ // ---- internals --------------------------------------------------------
212
+
213
+ function _hashSession(sessionId) {
214
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
215
+ }
216
+
217
+ async function _lastDetectionForCart(cartId) {
218
+ var r = await query(
219
+ "SELECT id, detected_at FROM cart_abandonment_detections " +
220
+ "WHERE cart_id = ?1 ORDER BY detected_at DESC LIMIT 1",
221
+ [cartId],
222
+ );
223
+ return r.rows[0] || null;
224
+ }
225
+
226
+ async function _openRun(startedAt) {
227
+ var id = _b().uuid.v7();
228
+ await query(
229
+ "INSERT INTO cart_abandonment_runs " +
230
+ "(id, started_at, status) VALUES (?1, ?2, 'running')",
231
+ [id, startedAt],
232
+ );
233
+ return id;
234
+ }
235
+
236
+ async function _closeRunOk(runId, finishedAt, scanned, detected) {
237
+ await query(
238
+ "UPDATE cart_abandonment_runs SET " +
239
+ "finished_at = ?1, carts_scanned = ?2, carts_detected = ?3, " +
240
+ "status = 'completed' WHERE id = ?4",
241
+ [finishedAt, scanned, detected, runId],
242
+ );
243
+ }
244
+
245
+ async function _closeRunFail(runId, finishedAt, scanned, detected, err) {
246
+ var msg = err && err.message ? String(err.message) : String(err || "unknown");
247
+ if (msg.length > 1024) msg = msg.slice(0, 1024);
248
+ await query(
249
+ "UPDATE cart_abandonment_runs SET " +
250
+ "finished_at = ?1, carts_scanned = ?2, carts_detected = ?3, " +
251
+ "status = 'failed', error = ?4 WHERE id = ?5",
252
+ [finishedAt, scanned, detected, msg, runId],
253
+ );
254
+ }
255
+
256
+ async function _bumpRunReminderSent(detectionId) {
257
+ // Find the most recent run that could plausibly own this
258
+ // detection (started before detection time + idle threshold).
259
+ // Operators that want a stricter mapping wire the detection ->
260
+ // run id explicitly; this loose mapping is fine for the
261
+ // dashboard counter that just shows "reminders dispatched on the
262
+ // last run."
263
+ var det = (await query(
264
+ "SELECT detected_at FROM cart_abandonment_detections WHERE id = ?1",
265
+ [detectionId],
266
+ )).rows[0];
267
+ if (!det) return;
268
+ var run = (await query(
269
+ "SELECT id FROM cart_abandonment_runs " +
270
+ "WHERE started_at <= ?1 ORDER BY started_at DESC LIMIT 1",
271
+ [det.detected_at],
272
+ )).rows[0];
273
+ if (!run) return;
274
+ await query(
275
+ "UPDATE cart_abandonment_runs SET reminders_sent = reminders_sent + 1 WHERE id = ?1",
276
+ [run.id],
277
+ );
278
+ }
279
+
280
+ // ---- surface ----------------------------------------------------------
281
+
282
+ return {
283
+ STATUSES: STATUSES,
284
+ SKIP_REASONS: SKIP_REASONS,
285
+ DEFAULT_IDLE_THRESHOLD_MS: DEFAULT_IDLE_THRESHOLD_MS,
286
+ DEFAULT_MAX_AGE_MS: DEFAULT_MAX_AGE_MS,
287
+ DEFAULT_MAX_CARTS: DEFAULT_MAX_CARTS,
288
+
289
+ // One scanner pass. Walks active carts older than the idle
290
+ // threshold, writes a detection row per new candidate, returns
291
+ // the candidate list so the operator's fan-out worker can issue
292
+ // reminders. Idempotent at the cart-window level: a cart already
293
+ // detected after `now - idle_threshold_ms` is skipped.
294
+ //
295
+ // Throws on bad arguments. Internal SELECT / INSERT errors mark
296
+ // the run row `status='failed'` (with the truncated message) and
297
+ // re-throw so the caller's observability sink sees the failure.
298
+ scan: async function (scanOpts) {
299
+ scanOpts = scanOpts || {};
300
+ var idleMs = scanOpts.idle_threshold_ms == null
301
+ ? DEFAULT_IDLE_THRESHOLD_MS
302
+ : scanOpts.idle_threshold_ms;
303
+ var maxAgeMs = scanOpts.max_age_ms == null
304
+ ? DEFAULT_MAX_AGE_MS
305
+ : scanOpts.max_age_ms;
306
+ var maxCarts = scanOpts.max_carts == null
307
+ ? DEFAULT_MAX_CARTS
308
+ : scanOpts.max_carts;
309
+ _positiveInt(idleMs, "idle_threshold_ms");
310
+ _positiveInt(maxAgeMs, "max_age_ms");
311
+ if (!Number.isInteger(maxCarts) || maxCarts <= 0 || maxCarts > MAX_MAX_CARTS) {
312
+ throw new TypeError(
313
+ "cart-abandonment: max_carts must be an integer in [1, " + MAX_MAX_CARTS + "]"
314
+ );
315
+ }
316
+ if (idleMs >= maxAgeMs) {
317
+ throw new TypeError(
318
+ "cart-abandonment: idle_threshold_ms must be strictly less than max_age_ms"
319
+ );
320
+ }
321
+
322
+ var now = _now();
323
+ var idleCutoff = now - idleMs;
324
+ var ageCutoff = now - maxAgeMs;
325
+ var runId = await _openRun(now);
326
+
327
+ var scanned = 0;
328
+ var detected = 0;
329
+ var candidates = [];
330
+
331
+ try {
332
+ // Select active carts whose updated_at falls in the
333
+ // (ageCutoff, idleCutoff] window. The ORDER BY oldest-first
334
+ // gives the operator deterministic forward progress on
335
+ // backlogs and matches the run cap semantics ("scan the
336
+ // first N idlest carts this pass").
337
+ var cartRows = (await query(
338
+ "SELECT id, session_id, customer_id, currency, updated_at FROM carts " +
339
+ "WHERE status = 'active' AND updated_at <= ?1 AND updated_at >= ?2 " +
340
+ "ORDER BY updated_at ASC LIMIT ?3",
341
+ [idleCutoff, ageCutoff, maxCarts],
342
+ )).rows;
343
+
344
+ for (var i = 0; i < cartRows.length; i += 1) {
345
+ var c = cartRows[i];
346
+ scanned += 1;
347
+
348
+ // Idempotency gate: a cart already detected after
349
+ // `idleCutoff` is still in the same idle window — skip.
350
+ // Re-detection only happens when the shopper bumps the
351
+ // cart (updated_at moves forward, the next idle window
352
+ // starts fresh).
353
+ var last = await _lastDetectionForCart(c.id);
354
+ if (last && last.detected_at > idleCutoff) continue;
355
+
356
+ // Aggregate line count + subtotal. Carts with zero lines
357
+ // are skipped (no items to remind about — the
358
+ // line_count > 0 CHECK on the detection row also enforces
359
+ // this defensively).
360
+ var agg = (await query(
361
+ "SELECT COUNT(*) AS line_count, " +
362
+ " COALESCE(SUM(qty * unit_amount_minor), 0) AS subtotal_minor " +
363
+ "FROM cart_lines WHERE cart_id = ?1",
364
+ [c.id],
365
+ )).rows[0];
366
+ var lineCount = Number(agg && agg.line_count || 0);
367
+ if (lineCount === 0) continue;
368
+ var subtotalMinor = Number(agg && agg.subtotal_minor || 0);
369
+
370
+ var detectionId = _b().uuid.v7();
371
+ var sessionHash = _hashSession(c.session_id);
372
+
373
+ // Race: a concurrent scanner could have written a
374
+ // detection for the same (cart_id, detected_at). The
375
+ // UNIQUE constraint makes the second writer fail; we
376
+ // catch + continue so the pass doesn't abort.
377
+ try {
378
+ await query(
379
+ "INSERT INTO cart_abandonment_detections " +
380
+ "(id, cart_id, session_id_hash, customer_id, line_count, " +
381
+ " subtotal_minor, subtotal_currency, detected_at, cart_idle_since, " +
382
+ " reminder_status) " +
383
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending')",
384
+ [
385
+ detectionId, c.id, sessionHash, c.customer_id || null,
386
+ lineCount, subtotalMinor, c.currency,
387
+ now, c.updated_at,
388
+ ],
389
+ );
390
+ } catch (e) {
391
+ // UNIQUE collision → another scanner beat us to it.
392
+ // Treat as "already detected this pass" and move on.
393
+ if (e && /UNIQUE/i.test(String(e.message || ""))) continue;
394
+ throw e;
395
+ }
396
+
397
+ detected += 1;
398
+ candidates.push({
399
+ detection_id: detectionId,
400
+ cart_id: c.id,
401
+ customer_id: c.customer_id || null,
402
+ line_count: lineCount,
403
+ subtotal_minor: subtotalMinor,
404
+ subtotal_currency: c.currency,
405
+ });
406
+ }
407
+
408
+ await _closeRunOk(runId, _now(), scanned, detected);
409
+ return {
410
+ run_id: runId,
411
+ carts_scanned: scanned,
412
+ carts_detected: detected,
413
+ candidates: candidates,
414
+ };
415
+ } catch (err) {
416
+ try { await _closeRunFail(runId, _now(), scanned, detected, err); }
417
+ catch (_e) { /* drop-silent — the original error is the one the caller needs */ }
418
+ throw err;
419
+ }
420
+ },
421
+
422
+ // Fan-out caller stamps "reminder dispatched." Idempotent on the
423
+ // detection row — re-calling with the same detection_id is a
424
+ // no-op (the row is already terminal). Bumps the owning run's
425
+ // `reminders_sent` counter so the dashboard surfaces "K
426
+ // reminders sent during the last run."
427
+ markReminderSent: async function (detectionId, markOpts) {
428
+ _uuid(detectionId, "detection_id");
429
+ markOpts = markOpts || {};
430
+ var sentAt = markOpts.sent_at == null ? _now() : markOpts.sent_at;
431
+ _epochMs(sentAt, "sent_at");
432
+ var r = await query(
433
+ "UPDATE cart_abandonment_detections SET " +
434
+ "reminder_status = 'sent', reminder_sent_at = ?1 " +
435
+ "WHERE id = ?2 AND reminder_status = 'pending'",
436
+ [sentAt, detectionId],
437
+ );
438
+ var changed = Number(r.rowCount || 0) > 0;
439
+ if (changed) {
440
+ try { await _bumpRunReminderSent(detectionId); }
441
+ catch (_e) { /* drop-silent — counter drift is recoverable; the detection row truth is authoritative */ }
442
+ }
443
+ return { detection_id: detectionId, changed: changed };
444
+ },
445
+
446
+ // Fan-out caller stamps "skipped — recipient unreachable /
447
+ // suppressed / opted-out." Distinct from `failed` (which is a
448
+ // transient delivery error) so the operator can split the
449
+ // dashboard accordingly.
450
+ markReminderSkipped: async function (detectionId, markOpts) {
451
+ _uuid(detectionId, "detection_id");
452
+ if (!markOpts || typeof markOpts !== "object") {
453
+ throw new TypeError(
454
+ "cart-abandonment.markReminderSkipped: opts.reason is required"
455
+ );
456
+ }
457
+ var reason = _skipReason(markOpts.reason);
458
+ var status = reason === "suppressed" || reason === "opted-out"
459
+ ? "skipped-suppressed"
460
+ : "skipped-no-email";
461
+ var r = await query(
462
+ "UPDATE cart_abandonment_detections SET " +
463
+ "reminder_status = ?1, reminder_skipped_reason = ?2 " +
464
+ "WHERE id = ?3 AND reminder_status = 'pending'",
465
+ [status, reason, detectionId],
466
+ );
467
+ return {
468
+ detection_id: detectionId,
469
+ changed: Number(r.rowCount || 0) > 0,
470
+ status: status,
471
+ reason: reason,
472
+ };
473
+ },
474
+
475
+ // Fan-out caller stamps "delivery failed." Truncates the error
476
+ // string so a runaway message can't bloat the row.
477
+ markReminderFailed: async function (detectionId, markOpts) {
478
+ _uuid(detectionId, "detection_id");
479
+ if (!markOpts || typeof markOpts !== "object" || typeof markOpts.error !== "string" || !markOpts.error.length) {
480
+ throw new TypeError(
481
+ "cart-abandonment.markReminderFailed: opts.error must be a non-empty string"
482
+ );
483
+ }
484
+ var msg = markOpts.error;
485
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(msg)) {
486
+ throw new TypeError(
487
+ "cart-abandonment.markReminderFailed: error must not contain control bytes"
488
+ );
489
+ }
490
+ if (msg.length > 1024) msg = msg.slice(0, 1024);
491
+ var r = await query(
492
+ "UPDATE cart_abandonment_detections SET " +
493
+ "reminder_status = 'failed', reminder_skipped_reason = ?1 " +
494
+ "WHERE id = ?2 AND reminder_status = 'pending'",
495
+ [msg, detectionId],
496
+ );
497
+ return {
498
+ detection_id: detectionId,
499
+ changed: Number(r.rowCount || 0) > 0,
500
+ };
501
+ },
502
+
503
+ // Operator-dashboard surface: recent detections, newest first,
504
+ // HMAC-tagged cursor on `(detected_at DESC, id DESC)`. Optional
505
+ // `status` filter narrows to one reminder-status bucket so the
506
+ // dashboard can render "pending reminders" and "failed
507
+ // reminders" as separate panels backed by the same primitive.
508
+ recentDetections: async function (listOpts) {
509
+ listOpts = listOpts || {};
510
+ var limit = _limit(listOpts.limit, "limit");
511
+ var status = null;
512
+ if (listOpts.status != null) {
513
+ _statusFilter(listOpts.status);
514
+ status = listOpts.status;
515
+ }
516
+ var cursorVals = null;
517
+ if (listOpts.cursor != null) {
518
+ if (typeof listOpts.cursor !== "string") {
519
+ throw new TypeError(
520
+ "cart-abandonment.recentDetections: cursor must be an opaque string"
521
+ );
522
+ }
523
+ try {
524
+ var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
525
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(RECENT_ORDER_KEY)) {
526
+ throw new TypeError(
527
+ "cart-abandonment.recentDetections: cursor orderKey mismatch"
528
+ );
529
+ }
530
+ cursorVals = state.vals;
531
+ } catch (e) {
532
+ if (e instanceof TypeError) throw e;
533
+ throw new TypeError(
534
+ "cart-abandonment.recentDetections: cursor — " + (e && e.message || "malformed")
535
+ );
536
+ }
537
+ }
538
+ var sql;
539
+ var params;
540
+ if (status != null && cursorVals) {
541
+ sql = "SELECT * FROM cart_abandonment_detections " +
542
+ "WHERE reminder_status = ?1 AND " +
543
+ "(detected_at < ?2 OR (detected_at = ?2 AND id < ?3)) " +
544
+ "ORDER BY detected_at DESC, id DESC LIMIT ?4";
545
+ params = [status, cursorVals[0], cursorVals[1], limit];
546
+ } else if (status != null) {
547
+ sql = "SELECT * FROM cart_abandonment_detections " +
548
+ "WHERE reminder_status = ?1 " +
549
+ "ORDER BY detected_at DESC, id DESC LIMIT ?2";
550
+ params = [status, limit];
551
+ } else if (cursorVals) {
552
+ sql = "SELECT * FROM cart_abandonment_detections WHERE " +
553
+ "(detected_at < ?1 OR (detected_at = ?1 AND id < ?2)) " +
554
+ "ORDER BY detected_at DESC, id DESC LIMIT ?3";
555
+ params = [cursorVals[0], cursorVals[1], limit];
556
+ } else {
557
+ sql = "SELECT * FROM cart_abandonment_detections " +
558
+ "ORDER BY detected_at DESC, id DESC LIMIT ?1";
559
+ params = [limit];
560
+ }
561
+ var rows = (await query(sql, params)).rows;
562
+ var last = rows[rows.length - 1];
563
+ var nextCursor = null;
564
+ if (last && rows.length === limit) {
565
+ nextCursor = _b().pagination.encodeCursor({
566
+ orderKey: RECENT_ORDER_KEY,
567
+ vals: [last.detected_at, last.id],
568
+ forward: true,
569
+ }, cursorSecret);
570
+ }
571
+ return { rows: rows, nextCursor: nextCursor };
572
+ },
573
+
574
+ // Per-run dashboard read. Returns the run row + a fresh count of
575
+ // detections that point at this run (loose mapping — the
576
+ // detection table doesn't carry the run id, so the count is
577
+ // "detections whose detected_at falls inside the run window").
578
+ statsForRun: async function (runId) {
579
+ _uuid(runId, "run_id");
580
+ var run = (await query(
581
+ "SELECT * FROM cart_abandonment_runs WHERE id = ?1",
582
+ [runId],
583
+ )).rows[0];
584
+ if (!run) return null;
585
+ var until = run.finished_at == null ? _now() : run.finished_at;
586
+ var detCount = Number(((await query(
587
+ "SELECT COUNT(*) AS n FROM cart_abandonment_detections " +
588
+ "WHERE detected_at >= ?1 AND detected_at <= ?2",
589
+ [run.started_at, until],
590
+ )).rows[0] || {}).n || 0);
591
+ return {
592
+ id: run.id,
593
+ started_at: run.started_at,
594
+ finished_at: run.finished_at,
595
+ status: run.status,
596
+ carts_scanned: Number(run.carts_scanned || 0),
597
+ carts_detected: Number(run.carts_detected || 0),
598
+ reminders_sent: Number(run.reminders_sent || 0),
599
+ error: run.error || null,
600
+ detections_in_window: detCount,
601
+ };
602
+ },
603
+
604
+ // Retention cleanup. Deletes detection rows with detected_at <
605
+ // before_ts. Run-row history is kept (small + useful for "did
606
+ // the cron stop?" diagnostics); operators trim it manually if it
607
+ // ever grows beyond their comfort window.
608
+ cleanupOld: async function (cleanupOpts) {
609
+ if (!cleanupOpts || typeof cleanupOpts !== "object") {
610
+ throw new TypeError(
611
+ "cart-abandonment.cleanupOld: opts.before_ts is required"
612
+ );
613
+ }
614
+ _epochMs(cleanupOpts.before_ts, "before_ts");
615
+ var r = await query(
616
+ "DELETE FROM cart_abandonment_detections WHERE detected_at < ?1",
617
+ [cleanupOpts.before_ts],
618
+ );
619
+ return { deleted: Number(r.rowCount || 0) };
620
+ },
621
+
622
+ // Internal access for tests / debugging. Lets callers grab a
623
+ // single detection by id without paginating through
624
+ // recentDetections.
625
+ _getDetection: async function (detectionId) {
626
+ _uuid(detectionId, "detection_id");
627
+ var r = await query(
628
+ "SELECT * FROM cart_abandonment_detections WHERE id = ?1",
629
+ [detectionId],
630
+ );
631
+ return r.rows[0] || null;
632
+ },
633
+
634
+ // Expose the optional deps so a wiring sanity check can assert
635
+ // they reached the factory. The cart dep is required so the
636
+ // accessor doesn't try to hide it.
637
+ _deps: {
638
+ cart: cart,
639
+ customers: customers,
640
+ emailSuppressions: emailSuppressions,
641
+ },
642
+
643
+ // Validators — exported on the surface so adjacent primitives
644
+ // (admin dashboards, observability shims) can re-use the same
645
+ // status / skip-reason allowlists without duplicating the
646
+ // constants.
647
+ _validateStatus: _statusFilter,
648
+ _validateSkipReason: _skipReason,
649
+ };
650
+ }
651
+
652
+ module.exports = {
653
+ create: create,
654
+ STATUSES: STATUSES,
655
+ SKIP_REASONS: SKIP_REASONS,
656
+ DEFAULT_IDLE_THRESHOLD_MS: DEFAULT_IDLE_THRESHOLD_MS,
657
+ DEFAULT_MAX_AGE_MS: DEFAULT_MAX_AGE_MS,
658
+ DEFAULT_MAX_CARTS: DEFAULT_MAX_CARTS,
659
+ };
660
+
661
+ // Validator helpers used inside `create()` referenced via closure;
662
+ // keep them outside the factory so test code can re-import them
663
+ // without spinning up a full factory instance.
664
+ void _nonNegInt;