@blamejs/blamejs-shop 0.0.66 → 0.0.72
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 +12 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -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/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +36 -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/loyalty-earn-rules.js +786 -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/pixel-events.js +995 -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/sitemap-generator.js +717 -0
- package/lib/split-shipments.js +7 -1
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -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,842 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.wishlistAlerts
|
|
4
|
+
* @title Notify customers when wishlist items go on sale or come back
|
|
5
|
+
* in stock.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Customers add items to their wishlist (the `wishlist` primitive);
|
|
9
|
+
* operators author one or more alert POLICIES through this primitive
|
|
10
|
+
* ("any price drop of 20%+", "any restock of a sold-out item"); the
|
|
11
|
+
* scheduler walks the wishlist + the catalog + the sent-ledger every
|
|
12
|
+
* N minutes and fires email through the injected `email` handle for
|
|
13
|
+
* each matching (customer, sku, policy) tuple.
|
|
14
|
+
*
|
|
15
|
+
* Surface:
|
|
16
|
+
*
|
|
17
|
+
* - defineAlertPolicy({ slug, trigger, threshold,
|
|
18
|
+
* max_alerts_per_week_per_customer? })
|
|
19
|
+
* Operator-authored policy row. `trigger` is one of
|
|
20
|
+
* `price_drop` / `back_in_stock` / `new_review_high_rating` /
|
|
21
|
+
* `restocked`. `threshold` is a plain-object payload whose
|
|
22
|
+
* validated shape depends on `trigger` — for price_drop it must
|
|
23
|
+
* carry `percent_off_bps_min` (1..10000 basis points; 2000 means
|
|
24
|
+
* "20% off or more"). For the other triggers it is `{}`.
|
|
25
|
+
* Refuses redefinition; operators reauthor by archiving + a
|
|
26
|
+
* fresh defineAlertPolicy.
|
|
27
|
+
*
|
|
28
|
+
* - scanAndDispatch({ now, batch_size? })
|
|
29
|
+
* Scheduler-callable. For each LIVE policy whose trigger has a
|
|
30
|
+
* wired source (price_drop + back_in_stock at v1), walks the
|
|
31
|
+
* wishlist table, resolves each entry's variant + sku + current
|
|
32
|
+
* price / inventory, evaluates the policy's condition, dedupes
|
|
33
|
+
* against the sent-ledger (no second fire for the same
|
|
34
|
+
* (customer, sku, policy) in 24h), respects the per-customer
|
|
35
|
+
* weekly cap, respects opt-outs in
|
|
36
|
+
* `wishlist_alert_unsubscribes`, and hands each match to the
|
|
37
|
+
* injected `email.sendWishlistDiscount` handle. Returns
|
|
38
|
+
* `{ scanned, candidates, sent, skipped }`.
|
|
39
|
+
*
|
|
40
|
+
* - markAlertSent({ alert_id, channel })
|
|
41
|
+
* Operator off-table delivery record — when the operator wires
|
|
42
|
+
* SMS or in-app delivery outside the `email` handle, this
|
|
43
|
+
* writes a wishlist_alerts_sent row for the matched policy /
|
|
44
|
+
* customer / sku without going through scanAndDispatch.
|
|
45
|
+
* `channel` ∈ {email, sms, in-app}.
|
|
46
|
+
*
|
|
47
|
+
* - customerAlertHistory({ customer_id, cursor?, limit? })
|
|
48
|
+
* Account-page surface — every alert the customer has received,
|
|
49
|
+
* newest first, paginated with the standard HMAC-tagged
|
|
50
|
+
* opaque cursor.
|
|
51
|
+
*
|
|
52
|
+
* - unsubscribeFromAlertKind({ customer_id, trigger })
|
|
53
|
+
* Customer opt-out lever; the dispatcher silently skips every
|
|
54
|
+
* row matching (customer_id, trigger) once this is set.
|
|
55
|
+
* Idempotent — re-unsubscribing is a no-op.
|
|
56
|
+
*
|
|
57
|
+
* - metricsForPolicy({ slug, from, to })
|
|
58
|
+
* Operator-dashboard rollup — count of sent alerts per policy
|
|
59
|
+
* in a time window, broken out by channel.
|
|
60
|
+
*
|
|
61
|
+
* Storage: wishlist_alert_policies, wishlist_alerts_sent,
|
|
62
|
+
* wishlist_alert_unsubscribes (migration 0156).
|
|
63
|
+
*
|
|
64
|
+
* Composes:
|
|
65
|
+
* - wishlist (required) — read the saved (customer, product,
|
|
66
|
+
* variant?) tuples.
|
|
67
|
+
* - catalog (required) — resolve variant → sku, current + prior
|
|
68
|
+
* price, current inventory.
|
|
69
|
+
* - email (required) — sendWishlistDiscount for price_drop;
|
|
70
|
+
* sendWishlistDiscount also serves back_in_stock with the
|
|
71
|
+
* price slot rendered as "—" so a single template covers both
|
|
72
|
+
* v1 triggers (operators wiring distinct templates inject a
|
|
73
|
+
* wrapper email handle).
|
|
74
|
+
* - stockAlerts (optional) — when wired, the scanner skips
|
|
75
|
+
* (customer, sku) pairs the customer is independently
|
|
76
|
+
* subscribed to via the PDP "notify me" toggle so the two
|
|
77
|
+
* paths don't double-deliver the same restock signal. Without
|
|
78
|
+
* it, every back_in_stock match fires regardless of stockAlerts
|
|
79
|
+
* state.
|
|
80
|
+
*
|
|
81
|
+
* Monotonic clock: a per-factory monotonic timestamp ensures that
|
|
82
|
+
* two alerts written in the same wall-clock millisecond carry
|
|
83
|
+
* strictly-increasing `occurred_at` values. The
|
|
84
|
+
* `(customer_id, occurred_at DESC)` index then returns the latest
|
|
85
|
+
* alert unambiguously and the keyset-paginated history is stable.
|
|
86
|
+
*
|
|
87
|
+
* @primitive wishlistAlerts
|
|
88
|
+
* @related shop.wishlist, shop.catalog, shop.email, shop.stockAlerts
|
|
89
|
+
*/
|
|
90
|
+
|
|
91
|
+
var bShop;
|
|
92
|
+
function _b() {
|
|
93
|
+
if (!bShop) bShop = require("./index");
|
|
94
|
+
return bShop.framework;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ---- constants ----------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
var TRIGGERS = ["price_drop", "back_in_stock", "new_review_high_rating", "restocked"];
|
|
100
|
+
var CHANNELS = ["email", "sms", "in-app"];
|
|
101
|
+
|
|
102
|
+
// Triggers the scanner has a wired source for at v1. The other two
|
|
103
|
+
// trigger kinds (new_review_high_rating + restocked) accept policies
|
|
104
|
+
// at definition time so a forward-compatible policy table can be
|
|
105
|
+
// authored before the matching event-source primitives land; the
|
|
106
|
+
// scanner skips them with `policy_source_unwired` accounted in the
|
|
107
|
+
// `skipped` count and never writes a sent-ledger row.
|
|
108
|
+
var SCANNABLE_TRIGGERS = ["price_drop", "back_in_stock"];
|
|
109
|
+
|
|
110
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
111
|
+
var MAX_SLUG_LEN = 80;
|
|
112
|
+
var MAX_LIST_LIMIT = 200;
|
|
113
|
+
var DEFAULT_LIMIT = 50;
|
|
114
|
+
var DEFAULT_BATCH_SIZE = 500;
|
|
115
|
+
var MAX_BATCH_SIZE = 5000;
|
|
116
|
+
var MAX_WEEKLY_CAP = 1000;
|
|
117
|
+
var MAX_BPS = 10000; // 100% — refuse strictly absurd thresholds
|
|
118
|
+
var MS_PER_DAY = 86400000;
|
|
119
|
+
var MS_PER_WEEK = MS_PER_DAY * 7;
|
|
120
|
+
|
|
121
|
+
// Per-(customer, sku, policy) re-fire suppression window. A price drop
|
|
122
|
+
// that triggers an alert today shouldn't re-fire tomorrow just because
|
|
123
|
+
// the variant's price moved again by a microscopic amount — the
|
|
124
|
+
// dispatcher refuses to write a fresh ledger row inside this window
|
|
125
|
+
// for the same triple.
|
|
126
|
+
var DEDUPE_WINDOW_MS = MS_PER_DAY;
|
|
127
|
+
|
|
128
|
+
var HISTORY_ORDER_KEY = ["occurred_at:desc", "id:desc"];
|
|
129
|
+
|
|
130
|
+
// ---- validators ---------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
function _slug(s) {
|
|
133
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
134
|
+
throw new TypeError("wishlistAlerts: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _trigger(s) {
|
|
140
|
+
if (typeof s !== "string" || TRIGGERS.indexOf(s) === -1) {
|
|
141
|
+
throw new TypeError("wishlistAlerts: trigger must be one of " + TRIGGERS.join(", "));
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _channel(s) {
|
|
147
|
+
if (typeof s !== "string" || CHANNELS.indexOf(s) === -1) {
|
|
148
|
+
throw new TypeError("wishlistAlerts: channel must be one of " + CHANNELS.join(", "));
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _uuid(s, label) {
|
|
154
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
155
|
+
catch (e) { throw new TypeError("wishlistAlerts: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _now(n) {
|
|
159
|
+
if (n == null) return Date.now();
|
|
160
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
161
|
+
throw new TypeError("wishlistAlerts: now must be a non-negative integer epoch-ms or null");
|
|
162
|
+
}
|
|
163
|
+
return n;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function _limit(n, label) {
|
|
167
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
168
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
169
|
+
throw new TypeError("wishlistAlerts: " + label + " must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
170
|
+
}
|
|
171
|
+
return n;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _batchSize(n) {
|
|
175
|
+
if (n == null) return DEFAULT_BATCH_SIZE;
|
|
176
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
|
|
177
|
+
throw new TypeError("wishlistAlerts: batch_size must be an integer in [1, " + MAX_BATCH_SIZE + "]");
|
|
178
|
+
}
|
|
179
|
+
return n;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _weeklyCap(n) {
|
|
183
|
+
if (n == null) return null;
|
|
184
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_WEEKLY_CAP) {
|
|
185
|
+
throw new TypeError("wishlistAlerts: max_alerts_per_week_per_customer must be a positive integer in [1, " + MAX_WEEKLY_CAP + "] or null");
|
|
186
|
+
}
|
|
187
|
+
return n;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _validateThresholdFor(trigger, threshold) {
|
|
191
|
+
if (threshold == null || typeof threshold !== "object" || Array.isArray(threshold)) {
|
|
192
|
+
throw new TypeError("wishlistAlerts: threshold must be a plain object");
|
|
193
|
+
}
|
|
194
|
+
if (trigger === "price_drop") {
|
|
195
|
+
if (!Number.isInteger(threshold.percent_off_bps_min)
|
|
196
|
+
|| threshold.percent_off_bps_min < 1
|
|
197
|
+
|| threshold.percent_off_bps_min > MAX_BPS) {
|
|
198
|
+
throw new TypeError(
|
|
199
|
+
"wishlistAlerts: threshold.percent_off_bps_min must be an integer in [1, " + MAX_BPS + "] for price_drop "
|
|
200
|
+
+ "(basis points; 2000 = 20%)",
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
// Refuse extras so a typo can't silently encode a different gate.
|
|
204
|
+
var keys = Object.keys(threshold);
|
|
205
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
206
|
+
if (keys[i] !== "percent_off_bps_min") {
|
|
207
|
+
throw new TypeError("wishlistAlerts: threshold for price_drop accepts only percent_off_bps_min (got " + JSON.stringify(keys[i]) + ")");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return { percent_off_bps_min: threshold.percent_off_bps_min };
|
|
211
|
+
}
|
|
212
|
+
// Other triggers — explicit empty-object contract.
|
|
213
|
+
if (Object.keys(threshold).length !== 0) {
|
|
214
|
+
throw new TypeError("wishlistAlerts: threshold for " + trigger + " must be an empty object at v1");
|
|
215
|
+
}
|
|
216
|
+
return {};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _hydratePolicy(r) {
|
|
220
|
+
if (!r) return null;
|
|
221
|
+
var thresh;
|
|
222
|
+
try { thresh = JSON.parse(r.threshold_json); }
|
|
223
|
+
catch (_e) { thresh = {}; }
|
|
224
|
+
return {
|
|
225
|
+
slug: r.slug,
|
|
226
|
+
trigger: r.trigger,
|
|
227
|
+
threshold: thresh,
|
|
228
|
+
max_alerts_per_week_per_customer: r.max_alerts_per_week_per_customer == null
|
|
229
|
+
? null
|
|
230
|
+
: Number(r.max_alerts_per_week_per_customer),
|
|
231
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
232
|
+
created_at: Number(r.created_at),
|
|
233
|
+
updated_at: Number(r.updated_at),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ---- factory ------------------------------------------------------------
|
|
238
|
+
|
|
239
|
+
function create(opts) {
|
|
240
|
+
opts = opts || {};
|
|
241
|
+
var query = opts.query;
|
|
242
|
+
if (!query) {
|
|
243
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Required composed handles. `wishlist`, `catalog`, and `email` are
|
|
247
|
+
// structural — the scanner can't function without them. `stockAlerts`
|
|
248
|
+
// is optional; when wired the scanner double-checks PDP-subscribed
|
|
249
|
+
// (customer, sku) pairs so back_in_stock doesn't duplicate.
|
|
250
|
+
var wishlist = opts.wishlist;
|
|
251
|
+
if (!wishlist || typeof wishlist.listForCustomer !== "function") {
|
|
252
|
+
throw new TypeError("wishlistAlerts.create: opts.wishlist must expose listForCustomer (the wishlist primitive)");
|
|
253
|
+
}
|
|
254
|
+
var catalog = opts.catalog;
|
|
255
|
+
if (!catalog
|
|
256
|
+
|| !catalog.variants || typeof catalog.variants.get !== "function"
|
|
257
|
+
|| !catalog.prices || typeof catalog.prices.current !== "function"
|
|
258
|
+
|| typeof catalog.prices.history !== "function"
|
|
259
|
+
|| !catalog.inventory || typeof catalog.inventory.get !== "function") {
|
|
260
|
+
throw new TypeError(
|
|
261
|
+
"wishlistAlerts.create: opts.catalog must expose variants.get, prices.current, prices.history, "
|
|
262
|
+
+ "inventory.get (the catalog primitive)",
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
var email = opts.email;
|
|
266
|
+
if (!email || typeof email.sendWishlistDiscount !== "function") {
|
|
267
|
+
throw new TypeError("wishlistAlerts.create: opts.email must expose sendWishlistDiscount (the email primitive)");
|
|
268
|
+
}
|
|
269
|
+
var stockAlerts = opts.stockAlerts || null;
|
|
270
|
+
if (stockAlerts && typeof stockAlerts.isSubscribed !== "function") {
|
|
271
|
+
throw new TypeError("wishlistAlerts.create: opts.stockAlerts must expose isSubscribed when wired");
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Pagination cursor secret — same posture as wishlist.create.
|
|
275
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
276
|
+
if (process.env.NODE_ENV === "production") {
|
|
277
|
+
throw new Error("wishlistAlerts.create: opts.cursorSecret is required in production");
|
|
278
|
+
}
|
|
279
|
+
opts.cursorSecret = "wishlist-alerts-cursor-secret-dev-only";
|
|
280
|
+
}
|
|
281
|
+
var cursorSecret = opts.cursorSecret;
|
|
282
|
+
|
|
283
|
+
// Default currency used to resolve catalog.prices.current / history.
|
|
284
|
+
// Operators with multi-currency catalogs inject a function via
|
|
285
|
+
// opts.currencyForCustomer(customer_id) for per-customer resolution;
|
|
286
|
+
// absent, the dispatcher falls back to a primitive-wide default.
|
|
287
|
+
var defaultCurrency = typeof opts.defaultCurrency === "string" && /^[A-Z]{3}$/.test(opts.defaultCurrency)
|
|
288
|
+
? opts.defaultCurrency
|
|
289
|
+
: "USD";
|
|
290
|
+
var currencyForCustomer = typeof opts.currencyForCustomer === "function" ? opts.currencyForCustomer : null;
|
|
291
|
+
|
|
292
|
+
// Per-factory monotonic clock. Two alerts written against the same
|
|
293
|
+
// (customer, sku) in the same wall-clock millisecond would otherwise
|
|
294
|
+
// tie on `occurred_at` and make the `(customer_id, occurred_at DESC)`
|
|
295
|
+
// index ambiguous. Forward-leap when the wall clock outpaces the
|
|
296
|
+
// counter; otherwise bump by 1ms.
|
|
297
|
+
var _lastTs = 0;
|
|
298
|
+
function _monotonicTs(now) {
|
|
299
|
+
var wall = now != null ? now : Date.now();
|
|
300
|
+
if (wall > _lastTs) _lastTs = wall;
|
|
301
|
+
else _lastTs += 1;
|
|
302
|
+
return _lastTs;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ---- defineAlertPolicy ------------------------------------------------
|
|
306
|
+
|
|
307
|
+
async function defineAlertPolicy(input) {
|
|
308
|
+
if (!input || typeof input !== "object") {
|
|
309
|
+
throw new TypeError("wishlistAlerts.defineAlertPolicy: input object required");
|
|
310
|
+
}
|
|
311
|
+
var slug = _slug(input.slug);
|
|
312
|
+
var trigger = _trigger(input.trigger);
|
|
313
|
+
var threshold = _validateThresholdFor(trigger, input.threshold);
|
|
314
|
+
var weeklyCap = _weeklyCap(input.max_alerts_per_week_per_customer);
|
|
315
|
+
|
|
316
|
+
var existing = (await query(
|
|
317
|
+
"SELECT slug FROM wishlist_alert_policies WHERE slug = ?1 LIMIT 1",
|
|
318
|
+
[slug],
|
|
319
|
+
)).rows[0];
|
|
320
|
+
if (existing) {
|
|
321
|
+
throw new TypeError("wishlistAlerts.defineAlertPolicy: slug " + JSON.stringify(slug) + " already exists - archive + redefine");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
var ts = _monotonicTs();
|
|
325
|
+
await query(
|
|
326
|
+
"INSERT INTO wishlist_alert_policies "
|
|
327
|
+
+ "(slug, trigger, threshold_json, max_alerts_per_week_per_customer, archived_at, created_at, updated_at) "
|
|
328
|
+
+ "VALUES (?1, ?2, ?3, ?4, NULL, ?5, ?5)",
|
|
329
|
+
[slug, trigger, JSON.stringify(threshold), weeklyCap, ts],
|
|
330
|
+
);
|
|
331
|
+
return await getAlertPolicy(slug);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function getAlertPolicy(slug) {
|
|
335
|
+
_slug(slug);
|
|
336
|
+
var r = (await query(
|
|
337
|
+
"SELECT * FROM wishlist_alert_policies WHERE slug = ?1 LIMIT 1",
|
|
338
|
+
[slug],
|
|
339
|
+
)).rows[0];
|
|
340
|
+
return _hydratePolicy(r);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function listAlertPolicies(listOpts) {
|
|
344
|
+
listOpts = listOpts || {};
|
|
345
|
+
var liveOnly = listOpts.live_only === true;
|
|
346
|
+
var limit = _limit(listOpts.limit, "limit");
|
|
347
|
+
var sql, params;
|
|
348
|
+
if (liveOnly) {
|
|
349
|
+
sql = "SELECT * FROM wishlist_alert_policies WHERE archived_at IS NULL "
|
|
350
|
+
+ "ORDER BY created_at ASC, slug ASC LIMIT ?1";
|
|
351
|
+
params = [limit];
|
|
352
|
+
} else {
|
|
353
|
+
sql = "SELECT * FROM wishlist_alert_policies "
|
|
354
|
+
+ "ORDER BY created_at ASC, slug ASC LIMIT ?1";
|
|
355
|
+
params = [limit];
|
|
356
|
+
}
|
|
357
|
+
var rows = (await query(sql, params)).rows;
|
|
358
|
+
var out = [];
|
|
359
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydratePolicy(rows[i]));
|
|
360
|
+
return out;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
async function archiveAlertPolicy(slug) {
|
|
364
|
+
_slug(slug);
|
|
365
|
+
var ts = _monotonicTs();
|
|
366
|
+
var r = await query(
|
|
367
|
+
"UPDATE wishlist_alert_policies SET archived_at = ?1, updated_at = ?1 "
|
|
368
|
+
+ "WHERE slug = ?2 AND archived_at IS NULL",
|
|
369
|
+
[ts, slug],
|
|
370
|
+
);
|
|
371
|
+
return { archived: Number(r.rowCount || 0) > 0 };
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---- unsubscribe + opt-out --------------------------------------------
|
|
375
|
+
|
|
376
|
+
async function unsubscribeFromAlertKind(input) {
|
|
377
|
+
if (!input || typeof input !== "object") {
|
|
378
|
+
throw new TypeError("wishlistAlerts.unsubscribeFromAlertKind: input object required");
|
|
379
|
+
}
|
|
380
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
381
|
+
var trigger = _trigger(input.trigger);
|
|
382
|
+
var ts = _monotonicTs(input.now);
|
|
383
|
+
|
|
384
|
+
var existing = (await query(
|
|
385
|
+
"SELECT id FROM wishlist_alert_unsubscribes WHERE customer_id = ?1 AND trigger = ?2 LIMIT 1",
|
|
386
|
+
[customerId, trigger],
|
|
387
|
+
)).rows[0];
|
|
388
|
+
if (existing) {
|
|
389
|
+
return { id: existing.id, status: "already-unsubscribed" };
|
|
390
|
+
}
|
|
391
|
+
var id = _b().uuid.v7();
|
|
392
|
+
await query(
|
|
393
|
+
"INSERT INTO wishlist_alert_unsubscribes (id, customer_id, trigger, occurred_at) "
|
|
394
|
+
+ "VALUES (?1, ?2, ?3, ?4)",
|
|
395
|
+
[id, customerId, trigger, ts],
|
|
396
|
+
);
|
|
397
|
+
return { id: id, status: "unsubscribed" };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function isUnsubscribedFromTrigger(customerId, trigger) {
|
|
401
|
+
var r = await query(
|
|
402
|
+
"SELECT id FROM wishlist_alert_unsubscribes WHERE customer_id = ?1 AND trigger = ?2 LIMIT 1",
|
|
403
|
+
[customerId, trigger],
|
|
404
|
+
);
|
|
405
|
+
return r.rows.length > 0;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---- ledger reads -----------------------------------------------------
|
|
409
|
+
|
|
410
|
+
async function customerAlertHistory(input) {
|
|
411
|
+
if (!input || typeof input !== "object") {
|
|
412
|
+
throw new TypeError("wishlistAlerts.customerAlertHistory: input object required");
|
|
413
|
+
}
|
|
414
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
415
|
+
var limit = _limit(input.limit, "limit");
|
|
416
|
+
var cursorVals = null;
|
|
417
|
+
if (input.cursor != null) {
|
|
418
|
+
if (typeof input.cursor !== "string") {
|
|
419
|
+
throw new TypeError("wishlistAlerts.customerAlertHistory: cursor must be an opaque string or null");
|
|
420
|
+
}
|
|
421
|
+
try {
|
|
422
|
+
var state = _b().pagination.decodeCursor(input.cursor, cursorSecret);
|
|
423
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(HISTORY_ORDER_KEY)) {
|
|
424
|
+
throw new TypeError("wishlistAlerts.customerAlertHistory: cursor orderKey mismatch");
|
|
425
|
+
}
|
|
426
|
+
cursorVals = state.vals;
|
|
427
|
+
} catch (e) {
|
|
428
|
+
if (e instanceof TypeError) throw e;
|
|
429
|
+
throw new TypeError("wishlistAlerts.customerAlertHistory: cursor — " + (e && e.message || "malformed"));
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
var sql, params;
|
|
433
|
+
if (cursorVals) {
|
|
434
|
+
sql = "SELECT * FROM wishlist_alerts_sent WHERE customer_id = ?1 AND "
|
|
435
|
+
+ "(occurred_at < ?2 OR (occurred_at = ?2 AND id < ?3)) "
|
|
436
|
+
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?4";
|
|
437
|
+
params = [customerId, cursorVals[0], cursorVals[1], limit];
|
|
438
|
+
} else {
|
|
439
|
+
sql = "SELECT * FROM wishlist_alerts_sent WHERE customer_id = ?1 "
|
|
440
|
+
+ "ORDER BY occurred_at DESC, id DESC LIMIT ?2";
|
|
441
|
+
params = [customerId, limit];
|
|
442
|
+
}
|
|
443
|
+
var rows = (await query(sql, params)).rows;
|
|
444
|
+
var hydrated = [];
|
|
445
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
446
|
+
var r = rows[i];
|
|
447
|
+
hydrated.push({
|
|
448
|
+
id: r.id,
|
|
449
|
+
customer_id: r.customer_id,
|
|
450
|
+
policy_slug: r.policy_slug,
|
|
451
|
+
sku: r.sku,
|
|
452
|
+
channel: r.channel,
|
|
453
|
+
occurred_at: Number(r.occurred_at),
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
var last = hydrated[hydrated.length - 1];
|
|
457
|
+
var nextCursor = null;
|
|
458
|
+
if (last && hydrated.length === limit) {
|
|
459
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
460
|
+
orderKey: HISTORY_ORDER_KEY,
|
|
461
|
+
vals: [last.occurred_at, last.id],
|
|
462
|
+
forward: true,
|
|
463
|
+
}, cursorSecret);
|
|
464
|
+
}
|
|
465
|
+
return { rows: hydrated, nextCursor: nextCursor };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function metricsForPolicy(input) {
|
|
469
|
+
if (!input || typeof input !== "object") {
|
|
470
|
+
throw new TypeError("wishlistAlerts.metricsForPolicy: input object required");
|
|
471
|
+
}
|
|
472
|
+
var slug = _slug(input.slug);
|
|
473
|
+
if (!Number.isInteger(input.from) || input.from < 0) {
|
|
474
|
+
throw new TypeError("wishlistAlerts.metricsForPolicy: from must be a non-negative integer epoch-ms");
|
|
475
|
+
}
|
|
476
|
+
if (!Number.isInteger(input.to) || input.to < input.from) {
|
|
477
|
+
throw new TypeError("wishlistAlerts.metricsForPolicy: to must be a non-negative integer epoch-ms >= from");
|
|
478
|
+
}
|
|
479
|
+
var r = await query(
|
|
480
|
+
"SELECT channel, COUNT(*) AS n FROM wishlist_alerts_sent "
|
|
481
|
+
+ "WHERE policy_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3 "
|
|
482
|
+
+ "GROUP BY channel",
|
|
483
|
+
[slug, input.from, input.to],
|
|
484
|
+
);
|
|
485
|
+
var byChannel = { email: 0, sms: 0, "in-app": 0 };
|
|
486
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
487
|
+
var row = r.rows[i];
|
|
488
|
+
byChannel[row.channel] = Number(row.n);
|
|
489
|
+
}
|
|
490
|
+
var total = byChannel.email + byChannel.sms + byChannel["in-app"];
|
|
491
|
+
return {
|
|
492
|
+
policy_slug: slug,
|
|
493
|
+
from: input.from,
|
|
494
|
+
to: input.to,
|
|
495
|
+
total: total,
|
|
496
|
+
by_channel: byChannel,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// ---- markAlertSent ----------------------------------------------------
|
|
501
|
+
|
|
502
|
+
async function markAlertSent(input) {
|
|
503
|
+
if (!input || typeof input !== "object") {
|
|
504
|
+
throw new TypeError("wishlistAlerts.markAlertSent: input object required");
|
|
505
|
+
}
|
|
506
|
+
var alertId = input.alert_id;
|
|
507
|
+
if (typeof alertId !== "string" || !alertId.length) {
|
|
508
|
+
throw new TypeError("wishlistAlerts.markAlertSent: alert_id must be a non-empty string");
|
|
509
|
+
}
|
|
510
|
+
var channel = _channel(input.channel);
|
|
511
|
+
var r = await query(
|
|
512
|
+
"UPDATE wishlist_alerts_sent SET channel = ?1 WHERE id = ?2",
|
|
513
|
+
[channel, alertId],
|
|
514
|
+
);
|
|
515
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
516
|
+
throw new TypeError("wishlistAlerts.markAlertSent: alert_id " + JSON.stringify(alertId) + " not found");
|
|
517
|
+
}
|
|
518
|
+
var row = (await query(
|
|
519
|
+
"SELECT * FROM wishlist_alerts_sent WHERE id = ?1 LIMIT 1",
|
|
520
|
+
[alertId],
|
|
521
|
+
)).rows[0];
|
|
522
|
+
return {
|
|
523
|
+
id: row.id,
|
|
524
|
+
customer_id: row.customer_id,
|
|
525
|
+
policy_slug: row.policy_slug,
|
|
526
|
+
sku: row.sku,
|
|
527
|
+
channel: row.channel,
|
|
528
|
+
occurred_at: Number(row.occurred_at),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---- scanAndDispatch internals ----------------------------------------
|
|
533
|
+
|
|
534
|
+
async function _countAlertsThisWeek(customerId, now) {
|
|
535
|
+
var since = now - MS_PER_WEEK;
|
|
536
|
+
var r = await query(
|
|
537
|
+
"SELECT COUNT(*) AS n FROM wishlist_alerts_sent "
|
|
538
|
+
+ "WHERE customer_id = ?1 AND occurred_at >= ?2",
|
|
539
|
+
[customerId, since],
|
|
540
|
+
);
|
|
541
|
+
return Number((r.rows[0] || {}).n || 0);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function _hasRecentFire(customerId, sku, policySlug, now) {
|
|
545
|
+
var since = now - DEDUPE_WINDOW_MS;
|
|
546
|
+
var r = await query(
|
|
547
|
+
"SELECT id FROM wishlist_alerts_sent "
|
|
548
|
+
+ "WHERE customer_id = ?1 AND sku = ?2 AND policy_slug = ?3 AND occurred_at >= ?4 "
|
|
549
|
+
+ "LIMIT 1",
|
|
550
|
+
[customerId, sku, policySlug, since],
|
|
551
|
+
);
|
|
552
|
+
return r.rows.length > 0;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// Walk every wishlist entry that has a non-null variant_id. Whole-
|
|
556
|
+
// product (variant_id IS NULL) rows are skipped at v1 — the policy
|
|
557
|
+
// evaluator can't resolve a single sku for them, and the operator-
|
|
558
|
+
// facing way to expand a product wishlist into per-variant alerts is
|
|
559
|
+
// for the storefront to write per-variant rows.
|
|
560
|
+
async function _allWishlistEntries(batchSize) {
|
|
561
|
+
var r = await query(
|
|
562
|
+
"SELECT id, customer_id, product_id, variant_id, created_at "
|
|
563
|
+
+ "FROM wishlist_entries WHERE variant_id IS NOT NULL "
|
|
564
|
+
+ "ORDER BY created_at ASC, id ASC LIMIT ?1",
|
|
565
|
+
[batchSize],
|
|
566
|
+
);
|
|
567
|
+
return r.rows;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
async function _resolveCurrency(customerId) {
|
|
571
|
+
if (currencyForCustomer) {
|
|
572
|
+
try {
|
|
573
|
+
var c = await currencyForCustomer(customerId);
|
|
574
|
+
if (typeof c === "string" && /^[A-Z]{3}$/.test(c)) return c;
|
|
575
|
+
} catch (_e) { /* drop-silent — fall through to default currency */ }
|
|
576
|
+
}
|
|
577
|
+
return defaultCurrency;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Evaluate a single (entry, policy) pair. Returns either:
|
|
581
|
+
// { fire: true, sku, payload } — dispatch + ledger
|
|
582
|
+
// { fire: false, reason } — accounted as `skipped`
|
|
583
|
+
async function _evaluate(entry, policy, now) {
|
|
584
|
+
// Map variant → sku.
|
|
585
|
+
var variant;
|
|
586
|
+
try { variant = await catalog.variants.get(entry.variant_id); }
|
|
587
|
+
catch (_e) { variant = null; }
|
|
588
|
+
if (!variant) return { fire: false, reason: "variant_unresolvable" };
|
|
589
|
+
var sku = variant.sku;
|
|
590
|
+
if (typeof sku !== "string" || !sku.length) return { fire: false, reason: "variant_has_no_sku" };
|
|
591
|
+
|
|
592
|
+
if (policy.trigger === "price_drop") {
|
|
593
|
+
var currency = await _resolveCurrency(entry.customer_id);
|
|
594
|
+
var current;
|
|
595
|
+
try { current = await catalog.prices.current(entry.variant_id, currency); }
|
|
596
|
+
catch (_e) { current = null; }
|
|
597
|
+
if (!current) return { fire: false, reason: "no_current_price" };
|
|
598
|
+
var history;
|
|
599
|
+
try { history = await catalog.prices.history(entry.variant_id, currency); }
|
|
600
|
+
catch (_e) { history = []; }
|
|
601
|
+
// The current row is in history (effective_until IS NULL); the
|
|
602
|
+
// baseline is the most recent CLOSED row (effective_until set).
|
|
603
|
+
var baseline = null;
|
|
604
|
+
for (var i = 0; i < history.length; i += 1) {
|
|
605
|
+
var h = history[i];
|
|
606
|
+
if (h.effective_until != null) { baseline = h; break; }
|
|
607
|
+
}
|
|
608
|
+
if (!baseline) return { fire: false, reason: "no_baseline_price" };
|
|
609
|
+
var baseAmt = Number(baseline.amount_minor);
|
|
610
|
+
var curAmt = Number(current.amount_minor);
|
|
611
|
+
if (!(baseAmt > 0) || !(curAmt >= 0) || curAmt >= baseAmt) {
|
|
612
|
+
return { fire: false, reason: "no_drop" };
|
|
613
|
+
}
|
|
614
|
+
// Percent-off in basis points: ((base - cur) / base) * 10000.
|
|
615
|
+
var bps = Math.floor(((baseAmt - curAmt) * 10000) / baseAmt);
|
|
616
|
+
var threshold = Number(policy.threshold.percent_off_bps_min);
|
|
617
|
+
if (bps < threshold) return { fire: false, reason: "below_threshold" };
|
|
618
|
+
return {
|
|
619
|
+
fire: true,
|
|
620
|
+
sku: sku,
|
|
621
|
+
payload: {
|
|
622
|
+
variant_id: entry.variant_id,
|
|
623
|
+
currency: currency,
|
|
624
|
+
old_amount_minor: baseAmt,
|
|
625
|
+
new_amount_minor: curAmt,
|
|
626
|
+
discount_bps: bps,
|
|
627
|
+
},
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (policy.trigger === "back_in_stock") {
|
|
632
|
+
var inv;
|
|
633
|
+
try { inv = await catalog.inventory.get(sku); }
|
|
634
|
+
catch (_e) { inv = null; }
|
|
635
|
+
if (!inv) return { fire: false, reason: "inventory_unresolvable" };
|
|
636
|
+
var available = Number(inv.stock_on_hand || 0) - Number(inv.stock_held || 0);
|
|
637
|
+
if (!(available > 0)) return { fire: false, reason: "still_out_of_stock" };
|
|
638
|
+
// Optional dep — when wired, suppress if the customer is already
|
|
639
|
+
// subscribed via the PDP "notify me" toggle. The dispatcher can
|
|
640
|
+
// only know the customer's email when we know they're a logged-
|
|
641
|
+
// in wishlist owner — and stockAlerts.isSubscribed is keyed by
|
|
642
|
+
// email, not customer_id, so we skip the cross-check unless the
|
|
643
|
+
// caller-supplied stockAlerts handle exposes a customer-keyed
|
|
644
|
+
// override. Keeping the cross-check shape forward-compatible:
|
|
645
|
+
// when stockAlerts is wired but doesn't expose `isSubscribedBy
|
|
646
|
+
// Customer`, the dispatcher fires anyway (no double-delivery
|
|
647
|
+
// suppression at v1, but the surface is in place).
|
|
648
|
+
if (stockAlerts && typeof stockAlerts.isSubscribedByCustomer === "function") {
|
|
649
|
+
var sub;
|
|
650
|
+
try { sub = await stockAlerts.isSubscribedByCustomer({ customer_id: entry.customer_id, sku: sku }); }
|
|
651
|
+
catch (_e) { sub = null; }
|
|
652
|
+
if (sub && sub.subscribed === true) return { fire: false, reason: "duplicate_via_stock_alerts" };
|
|
653
|
+
}
|
|
654
|
+
return {
|
|
655
|
+
fire: true,
|
|
656
|
+
sku: sku,
|
|
657
|
+
payload: {
|
|
658
|
+
variant_id: entry.variant_id,
|
|
659
|
+
available: available,
|
|
660
|
+
},
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
return { fire: false, reason: "policy_source_unwired" };
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Resolve a customer-facing email address. The wishlist primitive
|
|
668
|
+
// doesn't carry one — the operator's customer-store does. Callers
|
|
669
|
+
// that don't supply `opts.emailForCustomer` get a `no_email`
|
|
670
|
+
// skipped row. With `opts.emailForCustomer` wired, scanner asks
|
|
671
|
+
// for the customer's email per-fire (cache externally if hot).
|
|
672
|
+
var emailForCustomer = typeof opts.emailForCustomer === "function" ? opts.emailForCustomer : null;
|
|
673
|
+
|
|
674
|
+
async function scanAndDispatch(sweepOpts) {
|
|
675
|
+
sweepOpts = sweepOpts || {};
|
|
676
|
+
var now = _now(sweepOpts.now);
|
|
677
|
+
var batchSize = _batchSize(sweepOpts.batch_size);
|
|
678
|
+
|
|
679
|
+
var policies = await listAlertPolicies({ live_only: true, limit: MAX_LIST_LIMIT });
|
|
680
|
+
// Filter to scanner-wired triggers only; the others are accounted
|
|
681
|
+
// as `skipped` with `policy_source_unwired`.
|
|
682
|
+
var scannablePolicies = [];
|
|
683
|
+
var unwiredPolicyCount = 0;
|
|
684
|
+
for (var p = 0; p < policies.length; p += 1) {
|
|
685
|
+
if (SCANNABLE_TRIGGERS.indexOf(policies[p].trigger) === -1) {
|
|
686
|
+
unwiredPolicyCount += 1;
|
|
687
|
+
} else {
|
|
688
|
+
scannablePolicies.push(policies[p]);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
var entries = await _allWishlistEntries(batchSize);
|
|
693
|
+
var scanned = entries.length;
|
|
694
|
+
var candidates = 0;
|
|
695
|
+
var sent = 0;
|
|
696
|
+
var skipped = 0;
|
|
697
|
+
var skippedBy = {
|
|
698
|
+
unsubscribed: 0,
|
|
699
|
+
weekly_cap_reached: 0,
|
|
700
|
+
recent_dedupe: 0,
|
|
701
|
+
no_email: 0,
|
|
702
|
+
email_dispatch_failed: 0,
|
|
703
|
+
no_baseline_price: 0,
|
|
704
|
+
no_current_price: 0,
|
|
705
|
+
no_drop: 0,
|
|
706
|
+
below_threshold: 0,
|
|
707
|
+
still_out_of_stock: 0,
|
|
708
|
+
inventory_unresolvable: 0,
|
|
709
|
+
variant_unresolvable: 0,
|
|
710
|
+
variant_has_no_sku: 0,
|
|
711
|
+
duplicate_via_stock_alerts: 0,
|
|
712
|
+
policy_source_unwired: unwiredPolicyCount,
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
for (var e = 0; e < entries.length; e += 1) {
|
|
716
|
+
var entry = entries[e];
|
|
717
|
+
// Per-customer-per-policy outer evaluation: walk every active
|
|
718
|
+
// policy this entry could match.
|
|
719
|
+
for (var pp = 0; pp < scannablePolicies.length; pp += 1) {
|
|
720
|
+
var policy = scannablePolicies[pp];
|
|
721
|
+
|
|
722
|
+
// Cheapest cuts first.
|
|
723
|
+
var optedOut = await isUnsubscribedFromTrigger(entry.customer_id, policy.trigger);
|
|
724
|
+
if (optedOut) { skipped += 1; skippedBy.unsubscribed += 1; continue; }
|
|
725
|
+
|
|
726
|
+
if (policy.max_alerts_per_week_per_customer != null) {
|
|
727
|
+
var weekN = await _countAlertsThisWeek(entry.customer_id, now);
|
|
728
|
+
if (weekN >= policy.max_alerts_per_week_per_customer) {
|
|
729
|
+
skipped += 1; skippedBy.weekly_cap_reached += 1; continue;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Evaluate the policy condition.
|
|
734
|
+
var v = await _evaluate(entry, policy, now);
|
|
735
|
+
if (!v.fire) {
|
|
736
|
+
skipped += 1;
|
|
737
|
+
if (skippedBy[v.reason] != null) skippedBy[v.reason] += 1;
|
|
738
|
+
else skippedBy[v.reason] = 1;
|
|
739
|
+
continue;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// Per-(customer, sku, policy) recent-fire suppression.
|
|
743
|
+
var dup = await _hasRecentFire(entry.customer_id, v.sku, policy.slug, now);
|
|
744
|
+
if (dup) { skipped += 1; skippedBy.recent_dedupe += 1; continue; }
|
|
745
|
+
|
|
746
|
+
candidates += 1;
|
|
747
|
+
|
|
748
|
+
// Resolve the customer's email. Absent → no_email, skipped.
|
|
749
|
+
var customerEmail = null;
|
|
750
|
+
if (emailForCustomer) {
|
|
751
|
+
try { customerEmail = await emailForCustomer(entry.customer_id); }
|
|
752
|
+
catch (_e2) { customerEmail = null; }
|
|
753
|
+
}
|
|
754
|
+
if (typeof customerEmail !== "string" || !customerEmail.length) {
|
|
755
|
+
skipped += 1; skippedBy.no_email += 1; continue;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Resolve a product title for the email. Best-effort — falls
|
|
759
|
+
// through to the sku string when the catalog read fails.
|
|
760
|
+
var productTitle = v.sku;
|
|
761
|
+
try {
|
|
762
|
+
if (catalog.products && typeof catalog.products.get === "function") {
|
|
763
|
+
var prod = await catalog.products.get(entry.product_id);
|
|
764
|
+
if (prod && typeof prod.title === "string" && prod.title.length) productTitle = prod.title;
|
|
765
|
+
}
|
|
766
|
+
} catch (_e3) { /* drop-silent — title is cosmetic */ }
|
|
767
|
+
|
|
768
|
+
// Dispatch via email.sendWishlistDiscount. For price_drop we
|
|
769
|
+
// pass the formatted price; for back_in_stock we pass "—" so
|
|
770
|
+
// the same template covers both v1 triggers without requiring
|
|
771
|
+
// a second email primitive method.
|
|
772
|
+
var dispatchOk = false;
|
|
773
|
+
try {
|
|
774
|
+
var oldPriceStr = v.payload.old_amount_minor != null
|
|
775
|
+
? String(v.payload.old_amount_minor / 100) + " " + v.payload.currency
|
|
776
|
+
: "—";
|
|
777
|
+
var newPriceStr = v.payload.new_amount_minor != null
|
|
778
|
+
? String(v.payload.new_amount_minor / 100) + " " + v.payload.currency
|
|
779
|
+
: "—";
|
|
780
|
+
var pctStr = v.payload.discount_bps != null
|
|
781
|
+
? String(Math.floor(v.payload.discount_bps / 100)) + "%"
|
|
782
|
+
: "—";
|
|
783
|
+
await email.sendWishlistDiscount({
|
|
784
|
+
customer_email: customerEmail,
|
|
785
|
+
product_title: productTitle,
|
|
786
|
+
product_url: "/products/" + entry.product_id,
|
|
787
|
+
old_price: oldPriceStr,
|
|
788
|
+
new_price: newPriceStr,
|
|
789
|
+
discount_pct: pctStr,
|
|
790
|
+
});
|
|
791
|
+
dispatchOk = true;
|
|
792
|
+
} catch (_e4) {
|
|
793
|
+
// Drop-silent at the row level — a flapping mailer must not
|
|
794
|
+
// poison the rest of the scan. Account in skipped.
|
|
795
|
+
skipped += 1; skippedBy.email_dispatch_failed += 1; continue;
|
|
796
|
+
}
|
|
797
|
+
if (!dispatchOk) continue;
|
|
798
|
+
|
|
799
|
+
// Ledger write — the source of truth for "we already fired
|
|
800
|
+
// this alert" + the dedupe window.
|
|
801
|
+
var id = _b().uuid.v7();
|
|
802
|
+
var ts = _monotonicTs(now);
|
|
803
|
+
await query(
|
|
804
|
+
"INSERT INTO wishlist_alerts_sent (id, customer_id, policy_slug, sku, channel, occurred_at) "
|
|
805
|
+
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
806
|
+
[id, entry.customer_id, policy.slug, v.sku, "email", ts],
|
|
807
|
+
);
|
|
808
|
+
sent += 1;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
return {
|
|
813
|
+
scanned: scanned,
|
|
814
|
+
candidates: candidates,
|
|
815
|
+
sent: sent,
|
|
816
|
+
skipped: skipped,
|
|
817
|
+
skipped_by: skippedBy,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return {
|
|
822
|
+
TRIGGERS: TRIGGERS,
|
|
823
|
+
CHANNELS: CHANNELS,
|
|
824
|
+
|
|
825
|
+
defineAlertPolicy: defineAlertPolicy,
|
|
826
|
+
getAlertPolicy: getAlertPolicy,
|
|
827
|
+
listAlertPolicies: listAlertPolicies,
|
|
828
|
+
archiveAlertPolicy: archiveAlertPolicy,
|
|
829
|
+
|
|
830
|
+
scanAndDispatch: scanAndDispatch,
|
|
831
|
+
markAlertSent: markAlertSent,
|
|
832
|
+
customerAlertHistory: customerAlertHistory,
|
|
833
|
+
unsubscribeFromAlertKind: unsubscribeFromAlertKind,
|
|
834
|
+
metricsForPolicy: metricsForPolicy,
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
module.exports = {
|
|
839
|
+
create: create,
|
|
840
|
+
TRIGGERS: TRIGGERS,
|
|
841
|
+
CHANNELS: CHANNELS,
|
|
842
|
+
};
|