@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|