@blamejs/blamejs-shop 0.0.65 → 0.0.70
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 +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.reorderReminders
|
|
4
|
+
* @title Reorder reminders — customer-facing "time to reorder" nudges
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Distinct from `reorderThresholds` (the sibling primitive that
|
|
8
|
+
* answers "the operator's warehouse is low — propose a PO to the
|
|
9
|
+
* supplier"). This primitive answers a different question: "the
|
|
10
|
+
* customer's pantry is low — nudge them to reorder before they
|
|
11
|
+
* run out". Consumable categories (water filters, contact lenses,
|
|
12
|
+
* pet food, vitamins, coffee beans) have a predictable run-out
|
|
13
|
+
* cadence; missing the reorder window costs the operator a
|
|
14
|
+
* recurring-revenue line.
|
|
15
|
+
*
|
|
16
|
+
* The shape:
|
|
17
|
+
*
|
|
18
|
+
* var rr = b.shop.reorderReminders.create({
|
|
19
|
+
* query: q,
|
|
20
|
+
* order: orderPrimitive, // optional read-only dep
|
|
21
|
+
* notifications: notif, // wired for in_app dispatch
|
|
22
|
+
* email: email, // wired for email dispatch
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* // Operator defines the cadence for each consumable SKU.
|
|
26
|
+
* await rr.defineReminderProfile({
|
|
27
|
+
* sku: "FILTER-PUR-12M",
|
|
28
|
+
* interval_days: 365,
|
|
29
|
+
* message_template_slug: "reorder-filter-12m",
|
|
30
|
+
* channel: "email",
|
|
31
|
+
* });
|
|
32
|
+
*
|
|
33
|
+
* // The order-completion hook enrols the customer with the
|
|
34
|
+
* // recorded purchase timestamp; absent that timestamp, the
|
|
35
|
+
* // enrolment falls back to "now" and the first reminder fires
|
|
36
|
+
* // `interval_days` from today.
|
|
37
|
+
* await rr.enrollCustomerSku({
|
|
38
|
+
* customer_id: customerId,
|
|
39
|
+
* sku: "FILTER-PUR-12M",
|
|
40
|
+
* last_order_at: orderRow.placed_at,
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* // Scheduler tick — operator wires this to a cron / Workers
|
|
44
|
+
* // Cron Trigger. Pulls every active enrollment whose
|
|
45
|
+
* // next_remind_at is due, dispatches via the profile's channel,
|
|
46
|
+
* // and stamps a `reorder_dispatches` row per send.
|
|
47
|
+
* await rr.dispatchTick({ now: Date.now() });
|
|
48
|
+
*
|
|
49
|
+
* Verbs:
|
|
50
|
+
* defineReminderProfile — register / replace the SKU's cadence.
|
|
51
|
+
* Per-SKU uniqueness; redefining the active row patches it in
|
|
52
|
+
* place rather than archiving (the SKU's cadence is global,
|
|
53
|
+
* not versioned per customer).
|
|
54
|
+
*
|
|
55
|
+
* enrollCustomerSku — enrol a (customer_id, sku) pair on the
|
|
56
|
+
* given last_order_at. `next_remind_at` is computed from the
|
|
57
|
+
* profile's interval_days. Re-enrolling the same pair refreshes
|
|
58
|
+
* last_order_at and re-arms the next reminder — the operator
|
|
59
|
+
* calls this from every order-completion hook so the nudge
|
|
60
|
+
* window always tracks the freshest purchase.
|
|
61
|
+
*
|
|
62
|
+
* dispatchTick — pull every active enrollment whose
|
|
63
|
+
* next_remind_at <= now, walk each via the profile's channel,
|
|
64
|
+
* stamp a dispatches row, and advance next_remind_at by one
|
|
65
|
+
* interval. Returns the dispatched rows so the caller can log.
|
|
66
|
+
*
|
|
67
|
+
* recordSent / markFailed — terminal markers for the channel
|
|
68
|
+
* hooks. `dispatchTick` calls these inline when the channel
|
|
69
|
+
* composition returns; an operator with an async provider
|
|
70
|
+
* (deferred email gateway, SMS DLR callback) can also call
|
|
71
|
+
* them out-of-band against an `enrollment_id` they captured.
|
|
72
|
+
*
|
|
73
|
+
* remindersForCustomer — customer-portal read of an account's
|
|
74
|
+
* enrollments. Filter by status; cursor paginates oldest-first.
|
|
75
|
+
*
|
|
76
|
+
* unsubscribeFromSku — flip an enrollment to `cancelled` with
|
|
77
|
+
* the unsubscribed_at stamp. Idempotent. Future dispatchTick
|
|
78
|
+
* runs skip the row.
|
|
79
|
+
*
|
|
80
|
+
* metricsForProfile — count sent / failed dispatches in a
|
|
81
|
+
* window for one SKU. Powers the operator's "is this nudge
|
|
82
|
+
* converting" dashboard.
|
|
83
|
+
*
|
|
84
|
+
* Composition:
|
|
85
|
+
* - b.uuid.v7 — enrollment + dispatch row ids
|
|
86
|
+
* - b.guardUuid — customer_id sanitization at every entry point
|
|
87
|
+
* - notifications (optional) — `in_app` channel composes
|
|
88
|
+
* `notifications.enqueue`
|
|
89
|
+
* - email (optional) — `email` channel composes `email.send`
|
|
90
|
+
* (or any wired-shape `send({ to, template, ... })` hook)
|
|
91
|
+
* - sms (optional) — `sms` channel composes `sms.send`
|
|
92
|
+
*
|
|
93
|
+
* Three-tier input validation: every public verb is a config-time
|
|
94
|
+
* entry point (defineReminderProfile) or a defensive request-
|
|
95
|
+
* shape reader (everything else). All shapes throw on bad input;
|
|
96
|
+
* no drop-silent hot paths.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
var bShop;
|
|
100
|
+
function _b() {
|
|
101
|
+
if (!bShop) bShop = require("./index");
|
|
102
|
+
return bShop.framework;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- constants ----------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
108
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
|
|
109
|
+
var ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
110
|
+
var DAY_MS = 24 * 60 * 60 * 1000;
|
|
111
|
+
var MAX_INTERVAL_DAYS = 3650; // 10 years — same envelope as supplier lead times; refuses Number.MAX_SAFE_INTEGER typos
|
|
112
|
+
var MAX_BATCH_SIZE = 500;
|
|
113
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
114
|
+
var MAX_LIMIT = 500;
|
|
115
|
+
var DEFAULT_LIMIT = 50;
|
|
116
|
+
var MAX_REASON_LEN = 1024;
|
|
117
|
+
var CHANNELS = Object.freeze(["email", "sms", "in_app"]);
|
|
118
|
+
var STATUSES = Object.freeze(["active", "paused", "cancelled"]);
|
|
119
|
+
var DISPATCH_STATUSES = Object.freeze(["sent", "failed"]);
|
|
120
|
+
|
|
121
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
122
|
+
//
|
|
123
|
+
// Two enrollments / dispatches written inside the same millisecond would
|
|
124
|
+
// otherwise collide on the v7-uuid timestamp prefix and tie on
|
|
125
|
+
// (next_remind_at, id) ordering. The monotonic step guarantees strict-
|
|
126
|
+
// increase so the dispatcher's batch read returns a deterministic order
|
|
127
|
+
// without depending on the v7 sub-ms counter.
|
|
128
|
+
var _lastTs = 0;
|
|
129
|
+
function _now() {
|
|
130
|
+
var t = Date.now();
|
|
131
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
132
|
+
_lastTs = t;
|
|
133
|
+
return t;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- validators ---------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
function _sku(s) {
|
|
139
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
140
|
+
throw new TypeError("reorder-reminders: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
141
|
+
}
|
|
142
|
+
return s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _templateSlug(s) {
|
|
146
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
147
|
+
throw new TypeError("reorder-reminders: message_template_slug must match /^[a-z0-9][a-z0-9._-]*$/ (lowercase alnum + . _ -, 1..128 chars)");
|
|
148
|
+
}
|
|
149
|
+
return s;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _channel(s) {
|
|
153
|
+
if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
|
|
154
|
+
throw new TypeError("reorder-reminders: channel must be one of " + CHANNELS.join(", "));
|
|
155
|
+
}
|
|
156
|
+
return s;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _intervalDays(n) {
|
|
160
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_INTERVAL_DAYS) {
|
|
161
|
+
throw new TypeError("reorder-reminders: interval_days must be a positive integer ≤ " + MAX_INTERVAL_DAYS);
|
|
162
|
+
}
|
|
163
|
+
return n;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _customerId(s) {
|
|
167
|
+
try {
|
|
168
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
169
|
+
} catch (e) {
|
|
170
|
+
throw new TypeError("reorder-reminders: customer_id — " + (e && e.message || "invalid UUID"));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _id(s, label) {
|
|
175
|
+
if (typeof s !== "string" || !ID_RE.test(s)) {
|
|
176
|
+
throw new TypeError("reorder-reminders: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
177
|
+
}
|
|
178
|
+
return s;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _epochMs(n, label) {
|
|
182
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
183
|
+
throw new TypeError("reorder-reminders: " + label + " must be a non-negative integer (epoch ms)");
|
|
184
|
+
}
|
|
185
|
+
return n;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _optionalEpochMs(n, label) {
|
|
189
|
+
if (n == null) return null;
|
|
190
|
+
return _epochMs(n, label);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _status(s) {
|
|
194
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
195
|
+
throw new TypeError("reorder-reminders: status must be one of " + STATUSES.join(", "));
|
|
196
|
+
}
|
|
197
|
+
return s;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function _batchSize(n) {
|
|
201
|
+
if (n == null) return DEFAULT_BATCH_SIZE;
|
|
202
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
|
|
203
|
+
throw new TypeError("reorder-reminders: batch_size must be an integer in 1.." + MAX_BATCH_SIZE);
|
|
204
|
+
}
|
|
205
|
+
return n;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _limit(n) {
|
|
209
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
210
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
211
|
+
throw new TypeError("reorder-reminders: limit must be an integer in 1.." + MAX_LIMIT);
|
|
212
|
+
}
|
|
213
|
+
return n;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _failReason(s) {
|
|
217
|
+
if (typeof s !== "string" || !s.length) {
|
|
218
|
+
throw new TypeError("reorder-reminders: reason must be a non-empty string");
|
|
219
|
+
}
|
|
220
|
+
if (s.length > MAX_REASON_LEN) {
|
|
221
|
+
throw new TypeError("reorder-reminders: reason must be ≤ " + MAX_REASON_LEN + " characters");
|
|
222
|
+
}
|
|
223
|
+
return s;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- factory ------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function create(opts) {
|
|
229
|
+
opts = opts || {};
|
|
230
|
+
// `order` is held as an optional read-only marker — operators that
|
|
231
|
+
// want the primitive to backfill last_order_at from a recent order
|
|
232
|
+
// can compose that at the caller. The primitive never reads from
|
|
233
|
+
// this dep directly today; the factory accepts it so the wiring is
|
|
234
|
+
// explicit at construction time and a future verb can consume it
|
|
235
|
+
// without changing the public factory shape.
|
|
236
|
+
var order = opts.order || null;
|
|
237
|
+
if (order !== null && typeof order !== "object") {
|
|
238
|
+
throw new TypeError("reorder-reminders.create: opts.order must be an object or null");
|
|
239
|
+
}
|
|
240
|
+
var notifications = opts.notifications || null;
|
|
241
|
+
if (notifications !== null && typeof notifications !== "object") {
|
|
242
|
+
throw new TypeError("reorder-reminders.create: opts.notifications must be an object or null");
|
|
243
|
+
}
|
|
244
|
+
var email = opts.email || null;
|
|
245
|
+
if (email !== null && typeof email !== "object") {
|
|
246
|
+
throw new TypeError("reorder-reminders.create: opts.email must be an object or null");
|
|
247
|
+
}
|
|
248
|
+
var sms = opts.sms || null;
|
|
249
|
+
if (sms !== null && typeof sms !== "object") {
|
|
250
|
+
throw new TypeError("reorder-reminders.create: opts.sms must be an object or null");
|
|
251
|
+
}
|
|
252
|
+
var query = opts.query;
|
|
253
|
+
if (!query) {
|
|
254
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// ---- internal shapers ------------------------------------------------
|
|
258
|
+
|
|
259
|
+
function _shapeProfile(row) {
|
|
260
|
+
if (!row) return null;
|
|
261
|
+
return {
|
|
262
|
+
sku: row.sku,
|
|
263
|
+
interval_days: row.interval_days,
|
|
264
|
+
message_template_slug: row.message_template_slug,
|
|
265
|
+
channel: row.channel,
|
|
266
|
+
archived_at: row.archived_at,
|
|
267
|
+
created_at: row.created_at,
|
|
268
|
+
updated_at: row.updated_at,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _shapeEnrollment(row) {
|
|
273
|
+
if (!row) return null;
|
|
274
|
+
return {
|
|
275
|
+
id: row.id,
|
|
276
|
+
customer_id: row.customer_id,
|
|
277
|
+
sku: row.sku,
|
|
278
|
+
last_order_at: row.last_order_at,
|
|
279
|
+
next_remind_at: row.next_remind_at,
|
|
280
|
+
status: row.status,
|
|
281
|
+
unsubscribed_at: row.unsubscribed_at,
|
|
282
|
+
created_at: row.created_at,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function _shapeDispatch(row) {
|
|
287
|
+
if (!row) return null;
|
|
288
|
+
return {
|
|
289
|
+
id: row.id,
|
|
290
|
+
enrollment_id: row.enrollment_id,
|
|
291
|
+
status: row.status,
|
|
292
|
+
occurred_at: row.occurred_at,
|
|
293
|
+
fail_reason: row.fail_reason,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
async function _getActiveProfile(sku) {
|
|
298
|
+
var r = await query(
|
|
299
|
+
"SELECT * FROM reorder_profiles WHERE sku = ?1 AND archived_at IS NULL LIMIT 1",
|
|
300
|
+
[sku],
|
|
301
|
+
);
|
|
302
|
+
return r.rows[0] || null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function _getProfileBySku(sku) {
|
|
306
|
+
var r = await query(
|
|
307
|
+
"SELECT * FROM reorder_profiles WHERE sku = ?1 LIMIT 1",
|
|
308
|
+
[sku],
|
|
309
|
+
);
|
|
310
|
+
return r.rows[0] || null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function _getEnrollmentById(id) {
|
|
314
|
+
var r = await query(
|
|
315
|
+
"SELECT * FROM reorder_enrollments WHERE id = ?1 LIMIT 1",
|
|
316
|
+
[id],
|
|
317
|
+
);
|
|
318
|
+
return r.rows[0] || null;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function _getEnrollmentByPair(customerId, sku) {
|
|
322
|
+
var r = await query(
|
|
323
|
+
"SELECT * FROM reorder_enrollments WHERE customer_id = ?1 AND sku = ?2 LIMIT 1",
|
|
324
|
+
[customerId, sku],
|
|
325
|
+
);
|
|
326
|
+
return r.rows[0] || null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Dispatch one due enrollment through the profile's channel. Returns
|
|
330
|
+
// `{ ok: true }` on success, `{ ok: false, reason }` on failure. The
|
|
331
|
+
// channel deps are optional at factory time so the primitive can be
|
|
332
|
+
// exercised in isolation; a missing dep for a configured channel is
|
|
333
|
+
// a typed failure rather than a silent drop — the operator either
|
|
334
|
+
// wires the dep or archives the profile.
|
|
335
|
+
async function _dispatchOne(enrollment, profile) {
|
|
336
|
+
if (profile.channel === "in_app") {
|
|
337
|
+
if (!notifications || typeof notifications.enqueue !== "function") {
|
|
338
|
+
return { ok: false, reason: "notifications-dep-missing" };
|
|
339
|
+
}
|
|
340
|
+
try {
|
|
341
|
+
await notifications.enqueue({
|
|
342
|
+
recipient_id: enrollment.customer_id,
|
|
343
|
+
channel: "in-app",
|
|
344
|
+
event_type: "reorder.reminder",
|
|
345
|
+
title: "Time to reorder " + enrollment.sku,
|
|
346
|
+
body: "Reorder reminder for " + enrollment.sku,
|
|
347
|
+
payload: {
|
|
348
|
+
sku: enrollment.sku,
|
|
349
|
+
template_slug: profile.message_template_slug,
|
|
350
|
+
enrollment_id: enrollment.id,
|
|
351
|
+
},
|
|
352
|
+
});
|
|
353
|
+
return { ok: true };
|
|
354
|
+
} catch (e) {
|
|
355
|
+
return { ok: false, reason: "notifications-enqueue-failed: " + (e && e.message || "unknown") };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
if (profile.channel === "email") {
|
|
359
|
+
if (!email || typeof email.send !== "function") {
|
|
360
|
+
return { ok: false, reason: "email-dep-missing" };
|
|
361
|
+
}
|
|
362
|
+
try {
|
|
363
|
+
await email.send({
|
|
364
|
+
to: enrollment.customer_id,
|
|
365
|
+
template: profile.message_template_slug,
|
|
366
|
+
sku: enrollment.sku,
|
|
367
|
+
enrollment_id: enrollment.id,
|
|
368
|
+
});
|
|
369
|
+
return { ok: true };
|
|
370
|
+
} catch (e) {
|
|
371
|
+
return { ok: false, reason: "email-send-failed: " + (e && e.message || "unknown") };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
if (profile.channel === "sms") {
|
|
375
|
+
if (!sms || typeof sms.send !== "function") {
|
|
376
|
+
return { ok: false, reason: "sms-dep-missing" };
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
await sms.send({
|
|
380
|
+
customer_id: enrollment.customer_id,
|
|
381
|
+
template: profile.message_template_slug,
|
|
382
|
+
sku: enrollment.sku,
|
|
383
|
+
enrollment_id: enrollment.id,
|
|
384
|
+
});
|
|
385
|
+
return { ok: true };
|
|
386
|
+
} catch (e) {
|
|
387
|
+
return { ok: false, reason: "sms-send-failed: " + (e && e.message || "unknown") };
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Defensive: the CHECK constraint refuses any other value at the
|
|
391
|
+
// SQL boundary, but the branch is reached only via a corrupted
|
|
392
|
+
// row — surface a typed reason rather than a silent skip.
|
|
393
|
+
return { ok: false, reason: "unknown-channel: " + profile.channel };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
|
|
398
|
+
// Constants surfaced for tests + admin dashboards.
|
|
399
|
+
CHANNELS: CHANNELS,
|
|
400
|
+
STATUSES: STATUSES,
|
|
401
|
+
DISPATCH_STATUSES: DISPATCH_STATUSES,
|
|
402
|
+
|
|
403
|
+
// Register / replace the SKU's reorder cadence. The SKU is the PK
|
|
404
|
+
// — a profile's cadence is global to the catalog. Redefining the
|
|
405
|
+
// same SKU patches the existing row in place (interval_days /
|
|
406
|
+
// message_template_slug / channel) rather than archiving; the
|
|
407
|
+
// archived path is reserved for "stop nudging this SKU at all".
|
|
408
|
+
defineReminderProfile: async function (input) {
|
|
409
|
+
if (!input || typeof input !== "object") {
|
|
410
|
+
throw new TypeError("reorder-reminders.defineReminderProfile: input object required");
|
|
411
|
+
}
|
|
412
|
+
var sku = _sku(input.sku);
|
|
413
|
+
var intervalDays = _intervalDays(input.interval_days);
|
|
414
|
+
var messageTemplateSlug = _templateSlug(input.message_template_slug);
|
|
415
|
+
var channel = _channel(input.channel);
|
|
416
|
+
|
|
417
|
+
var existing = await _getProfileBySku(sku);
|
|
418
|
+
var ts = _now();
|
|
419
|
+
if (existing) {
|
|
420
|
+
// Patch in place — clearing archived_at if the operator is
|
|
421
|
+
// re-activating an archived profile.
|
|
422
|
+
await query(
|
|
423
|
+
"UPDATE reorder_profiles SET interval_days = ?1, message_template_slug = ?2, " +
|
|
424
|
+
"channel = ?3, archived_at = NULL, updated_at = ?4 WHERE sku = ?5",
|
|
425
|
+
[intervalDays, messageTemplateSlug, channel, ts, sku],
|
|
426
|
+
);
|
|
427
|
+
return _shapeProfile(await _getProfileBySku(sku));
|
|
428
|
+
}
|
|
429
|
+
await query(
|
|
430
|
+
"INSERT INTO reorder_profiles (sku, interval_days, message_template_slug, channel, " +
|
|
431
|
+
"archived_at, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
|
|
432
|
+
[sku, intervalDays, messageTemplateSlug, channel, ts],
|
|
433
|
+
);
|
|
434
|
+
return _shapeProfile(await _getProfileBySku(sku));
|
|
435
|
+
},
|
|
436
|
+
|
|
437
|
+
// Enrol a (customer_id, sku) pair. The profile MUST be active —
|
|
438
|
+
// an operator that archives the profile has paused the campaign
|
|
439
|
+
// and shouldn't be silently enrolling new customers. Re-enrolling
|
|
440
|
+
// the same pair refreshes last_order_at and re-arms next_remind_at
|
|
441
|
+
// (the order-completion hook is the canonical caller, and every
|
|
442
|
+
// fresh order should reset the cadence).
|
|
443
|
+
enrollCustomerSku: async function (input) {
|
|
444
|
+
if (!input || typeof input !== "object") {
|
|
445
|
+
throw new TypeError("reorder-reminders.enrollCustomerSku: input object required");
|
|
446
|
+
}
|
|
447
|
+
var customerId = _customerId(input.customer_id);
|
|
448
|
+
var sku = _sku(input.sku);
|
|
449
|
+
var lastOrderAt = input.last_order_at == null ? _now() : _epochMs(input.last_order_at, "last_order_at");
|
|
450
|
+
|
|
451
|
+
var profile = await _getActiveProfile(sku);
|
|
452
|
+
if (!profile) {
|
|
453
|
+
throw new TypeError("reorder-reminders.enrollCustomerSku: no active reminder profile for sku " +
|
|
454
|
+
JSON.stringify(sku) + " — call defineReminderProfile first");
|
|
455
|
+
}
|
|
456
|
+
var nextRemindAt = lastOrderAt + profile.interval_days * DAY_MS;
|
|
457
|
+
var ts = _now();
|
|
458
|
+
var existing = await _getEnrollmentByPair(customerId, sku);
|
|
459
|
+
if (existing) {
|
|
460
|
+
// Re-enrolment refreshes the cadence + clears any prior
|
|
461
|
+
// unsubscribe / paused state. Operators that want sticky opt-
|
|
462
|
+
// outs hold the customer's unsubscribe at a higher layer; this
|
|
463
|
+
// primitive treats a fresh order as an implicit re-opt-in,
|
|
464
|
+
// which matches the operator's intent for the hook ("they
|
|
465
|
+
// just bought it again — keep nudging them").
|
|
466
|
+
await query(
|
|
467
|
+
"UPDATE reorder_enrollments SET last_order_at = ?1, next_remind_at = ?2, " +
|
|
468
|
+
"status = 'active', unsubscribed_at = NULL WHERE id = ?3",
|
|
469
|
+
[lastOrderAt, nextRemindAt, existing.id],
|
|
470
|
+
);
|
|
471
|
+
return _shapeEnrollment(await _getEnrollmentById(existing.id));
|
|
472
|
+
}
|
|
473
|
+
var id = _b().uuid.v7({ now: ts });
|
|
474
|
+
await query(
|
|
475
|
+
"INSERT INTO reorder_enrollments (id, customer_id, sku, last_order_at, next_remind_at, " +
|
|
476
|
+
"status, unsubscribed_at, created_at) VALUES (?1, ?2, ?3, ?4, ?5, 'active', NULL, ?6)",
|
|
477
|
+
[id, customerId, sku, lastOrderAt, nextRemindAt, ts],
|
|
478
|
+
);
|
|
479
|
+
return _shapeEnrollment(await _getEnrollmentById(id));
|
|
480
|
+
},
|
|
481
|
+
|
|
482
|
+
// Scheduler tick: pull every active enrollment whose
|
|
483
|
+
// next_remind_at is due, dispatch via the profile's channel,
|
|
484
|
+
// stamp a dispatches row per attempt, and advance next_remind_at
|
|
485
|
+
// by one interval (on success only — failed rows stay at the
|
|
486
|
+
// current next_remind_at so the retry sweep picks them up on the
|
|
487
|
+
// next tick). Returns the dispatched rows so the caller can log.
|
|
488
|
+
dispatchTick: async function (input) {
|
|
489
|
+
input = input || {};
|
|
490
|
+
var now = input.now == null ? _now() : _epochMs(input.now, "now");
|
|
491
|
+
var batchSize = _batchSize(input.batch_size);
|
|
492
|
+
|
|
493
|
+
var due = await query(
|
|
494
|
+
"SELECT * FROM reorder_enrollments " +
|
|
495
|
+
"WHERE status = 'active' AND next_remind_at <= ?1 " +
|
|
496
|
+
"ORDER BY next_remind_at ASC, id ASC LIMIT ?2",
|
|
497
|
+
[now, batchSize],
|
|
498
|
+
);
|
|
499
|
+
|
|
500
|
+
var dispatched = [];
|
|
501
|
+
for (var i = 0; i < due.rows.length; i += 1) {
|
|
502
|
+
var enrollment = due.rows[i];
|
|
503
|
+
var profile = await _getActiveProfile(enrollment.sku);
|
|
504
|
+
var dispatchId = _b().uuid.v7({ now: _now() });
|
|
505
|
+
if (!profile) {
|
|
506
|
+
// Profile archived after enrollment — terminal-fail the
|
|
507
|
+
// attempt so the operator's metrics surface the
|
|
508
|
+
// misconfiguration. The enrollment stays active so the
|
|
509
|
+
// operator can re-define the profile and the next tick
|
|
510
|
+
// sweeps it.
|
|
511
|
+
await query(
|
|
512
|
+
"INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
|
|
513
|
+
"VALUES (?1, ?2, 'failed', ?3, ?4)",
|
|
514
|
+
[dispatchId, enrollment.id, _now(), "profile-archived-or-missing"],
|
|
515
|
+
);
|
|
516
|
+
dispatched.push(_shapeDispatch({
|
|
517
|
+
id: dispatchId,
|
|
518
|
+
enrollment_id: enrollment.id,
|
|
519
|
+
status: "failed",
|
|
520
|
+
occurred_at: now,
|
|
521
|
+
fail_reason: "profile-archived-or-missing",
|
|
522
|
+
}));
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
var result = await _dispatchOne(enrollment, profile);
|
|
527
|
+
var occurredAt = _now();
|
|
528
|
+
if (result.ok) {
|
|
529
|
+
await query(
|
|
530
|
+
"INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
|
|
531
|
+
"VALUES (?1, ?2, 'sent', ?3, NULL)",
|
|
532
|
+
[dispatchId, enrollment.id, occurredAt],
|
|
533
|
+
);
|
|
534
|
+
// Advance next_remind_at by one interval from the current
|
|
535
|
+
// next_remind_at (not from `now`) so a late dispatcher tick
|
|
536
|
+
// doesn't drift the cadence forward.
|
|
537
|
+
var nextRemindAt = enrollment.next_remind_at + profile.interval_days * DAY_MS;
|
|
538
|
+
await query(
|
|
539
|
+
"UPDATE reorder_enrollments SET next_remind_at = ?1 WHERE id = ?2",
|
|
540
|
+
[nextRemindAt, enrollment.id],
|
|
541
|
+
);
|
|
542
|
+
dispatched.push(_shapeDispatch({
|
|
543
|
+
id: dispatchId,
|
|
544
|
+
enrollment_id: enrollment.id,
|
|
545
|
+
status: "sent",
|
|
546
|
+
occurred_at: occurredAt,
|
|
547
|
+
fail_reason: null,
|
|
548
|
+
}));
|
|
549
|
+
} else {
|
|
550
|
+
await query(
|
|
551
|
+
"INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
|
|
552
|
+
"VALUES (?1, ?2, 'failed', ?3, ?4)",
|
|
553
|
+
[dispatchId, enrollment.id, occurredAt, result.reason],
|
|
554
|
+
);
|
|
555
|
+
dispatched.push(_shapeDispatch({
|
|
556
|
+
id: dispatchId,
|
|
557
|
+
enrollment_id: enrollment.id,
|
|
558
|
+
status: "failed",
|
|
559
|
+
occurred_at: occurredAt,
|
|
560
|
+
fail_reason: result.reason,
|
|
561
|
+
}));
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
return dispatched;
|
|
565
|
+
},
|
|
566
|
+
|
|
567
|
+
// Terminal marker for async channel hooks. The synchronous path
|
|
568
|
+
// through dispatchTick stamps these inline, but an async provider
|
|
569
|
+
// (deferred email gateway, SMS DLR callback) writes through here.
|
|
570
|
+
// The enrollment_id is the only key; reminder_id is the dispatch
|
|
571
|
+
// row id and is generated here, so the operator's hook only needs
|
|
572
|
+
// the enrollment.
|
|
573
|
+
recordSent: async function (input) {
|
|
574
|
+
if (!input || typeof input !== "object") {
|
|
575
|
+
throw new TypeError("reorder-reminders.recordSent: input object required");
|
|
576
|
+
}
|
|
577
|
+
var enrollmentId = _id(input.reminder_id || input.enrollment_id, "reminder_id");
|
|
578
|
+
var sentAt = input.sent_at == null ? _now() : _epochMs(input.sent_at, "sent_at");
|
|
579
|
+
var enrollment = await _getEnrollmentById(enrollmentId);
|
|
580
|
+
if (!enrollment) {
|
|
581
|
+
throw new TypeError("reorder-reminders.recordSent: enrollment_id " +
|
|
582
|
+
JSON.stringify(enrollmentId) + " not found");
|
|
583
|
+
}
|
|
584
|
+
var dispatchId = _b().uuid.v7({ now: _now() });
|
|
585
|
+
await query(
|
|
586
|
+
"INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
|
|
587
|
+
"VALUES (?1, ?2, 'sent', ?3, NULL)",
|
|
588
|
+
[dispatchId, enrollmentId, sentAt],
|
|
589
|
+
);
|
|
590
|
+
// Advance the cadence here too — recordSent is the terminal
|
|
591
|
+
// marker for a successful send regardless of which path
|
|
592
|
+
// (sync via dispatchTick / async via provider callback)
|
|
593
|
+
// produced the row.
|
|
594
|
+
var profile = await _getActiveProfile(enrollment.sku);
|
|
595
|
+
if (profile) {
|
|
596
|
+
var nextRemindAt = enrollment.next_remind_at + profile.interval_days * DAY_MS;
|
|
597
|
+
await query(
|
|
598
|
+
"UPDATE reorder_enrollments SET next_remind_at = ?1 WHERE id = ?2",
|
|
599
|
+
[nextRemindAt, enrollmentId],
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
var r = await query(
|
|
603
|
+
"SELECT * FROM reorder_dispatches WHERE id = ?1 LIMIT 1",
|
|
604
|
+
[dispatchId],
|
|
605
|
+
);
|
|
606
|
+
return _shapeDispatch(r.rows[0]);
|
|
607
|
+
},
|
|
608
|
+
|
|
609
|
+
markFailed: async function (input) {
|
|
610
|
+
if (!input || typeof input !== "object") {
|
|
611
|
+
throw new TypeError("reorder-reminders.markFailed: input object required");
|
|
612
|
+
}
|
|
613
|
+
var enrollmentId = _id(input.reminder_id || input.enrollment_id, "reminder_id");
|
|
614
|
+
var reason = _failReason(input.reason);
|
|
615
|
+
var enrollment = await _getEnrollmentById(enrollmentId);
|
|
616
|
+
if (!enrollment) {
|
|
617
|
+
throw new TypeError("reorder-reminders.markFailed: enrollment_id " +
|
|
618
|
+
JSON.stringify(enrollmentId) + " not found");
|
|
619
|
+
}
|
|
620
|
+
var occurredAt = _now();
|
|
621
|
+
var dispatchId = _b().uuid.v7({ now: occurredAt });
|
|
622
|
+
await query(
|
|
623
|
+
"INSERT INTO reorder_dispatches (id, enrollment_id, status, occurred_at, fail_reason) " +
|
|
624
|
+
"VALUES (?1, ?2, 'failed', ?3, ?4)",
|
|
625
|
+
[dispatchId, enrollmentId, occurredAt, reason],
|
|
626
|
+
);
|
|
627
|
+
var r = await query(
|
|
628
|
+
"SELECT * FROM reorder_dispatches WHERE id = ?1 LIMIT 1",
|
|
629
|
+
[dispatchId],
|
|
630
|
+
);
|
|
631
|
+
return _shapeDispatch(r.rows[0]);
|
|
632
|
+
},
|
|
633
|
+
|
|
634
|
+
// Customer-portal read. Defaults to all statuses; cursor is the
|
|
635
|
+
// created_at of the last row returned (oldest-first the customer
|
|
636
|
+
// sees their longest-running subscriptions). Optional `status`
|
|
637
|
+
// filter narrows to active / paused / cancelled.
|
|
638
|
+
remindersForCustomer: async function (input) {
|
|
639
|
+
if (!input || typeof input !== "object") {
|
|
640
|
+
throw new TypeError("reorder-reminders.remindersForCustomer: input object required");
|
|
641
|
+
}
|
|
642
|
+
var customerId = _customerId(input.customer_id);
|
|
643
|
+
var limit = _limit(input.limit);
|
|
644
|
+
var cursor = _optionalEpochMs(input.cursor, "cursor");
|
|
645
|
+
var status = input.status == null ? null : _status(input.status);
|
|
646
|
+
|
|
647
|
+
var clauses = ["customer_id = ?1"];
|
|
648
|
+
var params = [customerId];
|
|
649
|
+
var idx = 2;
|
|
650
|
+
if (status) {
|
|
651
|
+
clauses.push("status = ?" + idx); params.push(status); idx += 1;
|
|
652
|
+
}
|
|
653
|
+
if (cursor != null) {
|
|
654
|
+
clauses.push("created_at > ?" + idx); params.push(cursor); idx += 1;
|
|
655
|
+
}
|
|
656
|
+
params.push(limit);
|
|
657
|
+
var sql = "SELECT * FROM reorder_enrollments WHERE " + clauses.join(" AND ") +
|
|
658
|
+
" ORDER BY created_at ASC, id ASC LIMIT ?" + idx;
|
|
659
|
+
var r = await query(sql, params);
|
|
660
|
+
var rows = r.rows.map(_shapeEnrollment);
|
|
661
|
+
var nextCursor = null;
|
|
662
|
+
if (rows.length === limit) {
|
|
663
|
+
nextCursor = Number(rows[rows.length - 1].created_at);
|
|
664
|
+
}
|
|
665
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
666
|
+
},
|
|
667
|
+
|
|
668
|
+
// Flip an enrollment to `cancelled` with the unsubscribed_at
|
|
669
|
+
// stamp. Idempotent — re-cancelling preserves the original
|
|
670
|
+
// unsubscribed_at. Future dispatchTick runs skip the row because
|
|
671
|
+
// the partial index targets `status = 'active'`.
|
|
672
|
+
unsubscribeFromSku: async function (input) {
|
|
673
|
+
if (!input || typeof input !== "object") {
|
|
674
|
+
throw new TypeError("reorder-reminders.unsubscribeFromSku: input object required");
|
|
675
|
+
}
|
|
676
|
+
var customerId = _customerId(input.customer_id);
|
|
677
|
+
var sku = _sku(input.sku);
|
|
678
|
+
var existing = await _getEnrollmentByPair(customerId, sku);
|
|
679
|
+
if (!existing) {
|
|
680
|
+
throw new TypeError("reorder-reminders.unsubscribeFromSku: no enrollment for customer " +
|
|
681
|
+
JSON.stringify(customerId) + " sku " + JSON.stringify(sku));
|
|
682
|
+
}
|
|
683
|
+
if (existing.status === "cancelled") {
|
|
684
|
+
return _shapeEnrollment(existing);
|
|
685
|
+
}
|
|
686
|
+
var ts = _now();
|
|
687
|
+
await query(
|
|
688
|
+
"UPDATE reorder_enrollments SET status = 'cancelled', unsubscribed_at = ?1 WHERE id = ?2",
|
|
689
|
+
[ts, existing.id],
|
|
690
|
+
);
|
|
691
|
+
return _shapeEnrollment(await _getEnrollmentById(existing.id));
|
|
692
|
+
},
|
|
693
|
+
|
|
694
|
+
// Window-scoped metrics. Counts sent / failed dispatches for one
|
|
695
|
+
// SKU between `from` and `to`. The operator's dashboard powers
|
|
696
|
+
// "what's the success rate for this nudge campaign" off this.
|
|
697
|
+
metricsForProfile: async function (input) {
|
|
698
|
+
if (!input || typeof input !== "object") {
|
|
699
|
+
throw new TypeError("reorder-reminders.metricsForProfile: input object required");
|
|
700
|
+
}
|
|
701
|
+
var sku = _sku(input.sku);
|
|
702
|
+
var from = _epochMs(input.from, "from");
|
|
703
|
+
var to = _epochMs(input.to, "to");
|
|
704
|
+
if (to < from) {
|
|
705
|
+
throw new TypeError("reorder-reminders.metricsForProfile: to (" + to +
|
|
706
|
+
") must be ≥ from (" + from + ")");
|
|
707
|
+
}
|
|
708
|
+
var r = await query(
|
|
709
|
+
"SELECT d.status AS s, COUNT(*) AS c FROM reorder_dispatches d " +
|
|
710
|
+
"JOIN reorder_enrollments e ON e.id = d.enrollment_id " +
|
|
711
|
+
"WHERE e.sku = ?1 AND d.occurred_at >= ?2 AND d.occurred_at <= ?3 " +
|
|
712
|
+
"GROUP BY d.status",
|
|
713
|
+
[sku, from, to],
|
|
714
|
+
);
|
|
715
|
+
var sent = 0;
|
|
716
|
+
var failed = 0;
|
|
717
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
718
|
+
var row = r.rows[i];
|
|
719
|
+
if (row.s === "sent") sent = Number(row.c) || 0;
|
|
720
|
+
else if (row.s === "failed") failed = Number(row.c) || 0;
|
|
721
|
+
}
|
|
722
|
+
return {
|
|
723
|
+
sku: sku,
|
|
724
|
+
from: from,
|
|
725
|
+
to: to,
|
|
726
|
+
sent_count: sent,
|
|
727
|
+
failed_count: failed,
|
|
728
|
+
total_count: sent + failed,
|
|
729
|
+
};
|
|
730
|
+
},
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Top-level `run()` for direct invocation — exercises the primitive's
|
|
735
|
+
// factory shape against an in-memory query stub so the smoke caller
|
|
736
|
+
// (operator's release pipeline) can confirm the module loads + the
|
|
737
|
+
// factory composes without touching a remote D1 or a migration file.
|
|
738
|
+
// The stub is a minimal in-memory map indexed by SQL verb; it covers
|
|
739
|
+
// enough of the surface to round-trip a defineReminderProfile call.
|
|
740
|
+
async function run() {
|
|
741
|
+
var profiles = {};
|
|
742
|
+
var enrolments = [];
|
|
743
|
+
var dispatches = [];
|
|
744
|
+
var q = async function (sql, params) {
|
|
745
|
+
params = params || [];
|
|
746
|
+
var verb = sql.replace(/^\s+/, "").split(/\s+/)[0].toUpperCase();
|
|
747
|
+
if (verb === "SELECT" && /FROM reorder_profiles/.test(sql)) {
|
|
748
|
+
var sku = params[0];
|
|
749
|
+
var p = profiles[sku];
|
|
750
|
+
return { rows: p ? [p] : [], rowCount: p ? 1 : 0 };
|
|
751
|
+
}
|
|
752
|
+
if (verb === "INSERT" && /reorder_profiles/.test(sql)) {
|
|
753
|
+
profiles[params[0]] = {
|
|
754
|
+
sku: params[0],
|
|
755
|
+
interval_days: params[1],
|
|
756
|
+
message_template_slug: params[2],
|
|
757
|
+
channel: params[3],
|
|
758
|
+
archived_at: null,
|
|
759
|
+
created_at: params[4],
|
|
760
|
+
updated_at: params[4],
|
|
761
|
+
};
|
|
762
|
+
return { rows: [], rowCount: 1 };
|
|
763
|
+
}
|
|
764
|
+
if (verb === "UPDATE" && /reorder_profiles/.test(sql)) {
|
|
765
|
+
var existing = profiles[params[4]];
|
|
766
|
+
if (existing) {
|
|
767
|
+
existing.interval_days = params[0];
|
|
768
|
+
existing.message_template_slug = params[1];
|
|
769
|
+
existing.channel = params[2];
|
|
770
|
+
existing.archived_at = null;
|
|
771
|
+
existing.updated_at = params[3];
|
|
772
|
+
}
|
|
773
|
+
return { rows: [], rowCount: existing ? 1 : 0 };
|
|
774
|
+
}
|
|
775
|
+
// Untouched paths return an empty result — the smoke caller
|
|
776
|
+
// only exercises defineReminderProfile.
|
|
777
|
+
return { rows: [], rowCount: 0 };
|
|
778
|
+
};
|
|
779
|
+
// Keep the unused locals referenced so a future smoke extension can
|
|
780
|
+
// walk the full surface without a dangling-variable lint trip.
|
|
781
|
+
void enrolments; void dispatches;
|
|
782
|
+
var rr = create({ query: q });
|
|
783
|
+
await rr.defineReminderProfile({
|
|
784
|
+
sku: "FILTER-PUR-12M",
|
|
785
|
+
interval_days: 365,
|
|
786
|
+
message_template_slug: "reorder-filter-12m",
|
|
787
|
+
channel: "in_app",
|
|
788
|
+
});
|
|
789
|
+
return { ok: true };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
module.exports = {
|
|
793
|
+
create: create,
|
|
794
|
+
run: run,
|
|
795
|
+
CHANNELS: CHANNELS,
|
|
796
|
+
STATUSES: STATUSES,
|
|
797
|
+
DISPATCH_STATUSES: DISPATCH_STATUSES,
|
|
798
|
+
};
|