@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.
- package/CHANGELOG.md +4 -0
- package/lib/addresses.js +430 -0
- package/lib/cart-abandonment.js +664 -0
- package/lib/currency-display.js +432 -0
- package/lib/email-suppressions.js +579 -0
- package/lib/index.js +12 -1
- package/lib/loyalty.js +496 -0
- package/lib/notifications.js +474 -0
- package/lib/order-tracking.js +456 -0
- package/lib/referrals.js +649 -0
- package/lib/returns.js +627 -0
- package/lib/search-suggestions.js +528 -0
- package/lib/tax-exempt.js +519 -0
- package/lib/vendor/MANIFEST.json +1 -1
- package/package.json +1 -1
|
@@ -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;
|