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