@blamejs/blamejs-shop 0.0.70 → 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 +10 -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 +42 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/loyalty-earn-rules.js +786 -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/split-shipments.js +7 -1
- 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,786 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.loyaltyEarnRules
|
|
4
|
+
* @title Loyalty earn rules — per-action point-earning configuration
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Distinct from `loyalty` (which records the running points balance
|
|
8
|
+
* and the audited transaction trail) and from `tierBenefits` (which
|
|
9
|
+
* configures perks unlocked at each tier). This primitive defines
|
|
10
|
+
* HOW points are earned — operators publish rules keyed by an event
|
|
11
|
+
* `trigger` and the application calls `awardForEvent` at the
|
|
12
|
+
* appropriate lifecycle moment.
|
|
13
|
+
*
|
|
14
|
+
* Triggers (closed enum):
|
|
15
|
+
* - per_dollar_spent — N points per $1 of order subtotal
|
|
16
|
+
* - per_purchase — flat N points per completed order
|
|
17
|
+
* - per_review — N points per review submitted
|
|
18
|
+
* - per_referral_redeemed — N points per referred friend's
|
|
19
|
+
* first order completing
|
|
20
|
+
* - birthday — N points on the customer's birthday
|
|
21
|
+
* - signup_bonus — N points on account creation
|
|
22
|
+
* - first_purchase — N points on the first completed order
|
|
23
|
+
* - abandoned_cart_recovered — N points when a recovered cart converts
|
|
24
|
+
*
|
|
25
|
+
* Composition:
|
|
26
|
+
*
|
|
27
|
+
* var rules = bShop.loyaltyEarnRules.create({
|
|
28
|
+
* query: q,
|
|
29
|
+
* loyalty: loy, // optional — awardForEvent composes loy.earn
|
|
30
|
+
* });
|
|
31
|
+
*
|
|
32
|
+
* await rules.defineRule({
|
|
33
|
+
* slug: "spend-1pt-per-dollar",
|
|
34
|
+
* trigger: "per_dollar_spent",
|
|
35
|
+
* points_per_unit: 1,
|
|
36
|
+
* max_per_event: 5000,
|
|
37
|
+
* customer_status_in: ["active", "vip"],
|
|
38
|
+
* });
|
|
39
|
+
*
|
|
40
|
+
* await rules.awardForEvent({
|
|
41
|
+
* trigger: "per_dollar_spent",
|
|
42
|
+
* customer_id: customerId,
|
|
43
|
+
* dollars_spent: 42,
|
|
44
|
+
* trigger_event_ref: "order:" + orderId,
|
|
45
|
+
* customer_status: "active",
|
|
46
|
+
* });
|
|
47
|
+
*
|
|
48
|
+
* `evaluateForEvent` is the dry-run companion to `awardForEvent`. It
|
|
49
|
+
* returns the same `{ points, reason }` shape but does NOT touch the
|
|
50
|
+
* audit log or the loyalty ledger — operators preview an award at
|
|
51
|
+
* checkout (so the customer sees "you'll earn 42 points") without
|
|
52
|
+
* committing.
|
|
53
|
+
*
|
|
54
|
+
* `applyBatch` runs multiple awards in a single call. Each (rule,
|
|
55
|
+
* event) pair flows through the same validate -> evaluate -> award
|
|
56
|
+
* path; per-pair failures are collected into a `failed[]` array
|
|
57
|
+
* rather than failing the whole batch (operators commonly run nightly
|
|
58
|
+
* sweeps that span thousands of events — a malformed row shouldn't
|
|
59
|
+
* block the rest).
|
|
60
|
+
*
|
|
61
|
+
* Per-event dedup: the (rule_slug, customer_id, trigger_event_ref)
|
|
62
|
+
* UNIQUE on `loyalty_earn_log` collapses retried inserts onto one
|
|
63
|
+
* row so a webhook retry doesn't double-award.
|
|
64
|
+
*
|
|
65
|
+
* Composes:
|
|
66
|
+
* - `b.uuid.v7` — audit-log row ids (lexicographic + monotonic)
|
|
67
|
+
* - `b.guardUuid` — strict UUID gate on every customer_id
|
|
68
|
+
* - `loyalty` — optional; when wired, `awardForEvent` composes
|
|
69
|
+
* `loyalty.earn` so the points land in the
|
|
70
|
+
* customer's balance + the loyalty audit trail
|
|
71
|
+
* in one call.
|
|
72
|
+
*
|
|
73
|
+
* Storage: `migrations-d1/0163_loyalty_earn_rules.sql` —
|
|
74
|
+
* `loyalty_earn_rules` + `loyalty_earn_log`.
|
|
75
|
+
*
|
|
76
|
+
* @primitive loyaltyEarnRules
|
|
77
|
+
* @related loyalty, tierBenefits, b.uuid.v7, b.guardUuid
|
|
78
|
+
*/
|
|
79
|
+
|
|
80
|
+
var bShop;
|
|
81
|
+
function _b() {
|
|
82
|
+
if (!bShop) bShop = require("./index");
|
|
83
|
+
return bShop.framework;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---- constants ----------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
var TRIGGERS = Object.freeze([
|
|
89
|
+
"per_dollar_spent",
|
|
90
|
+
"per_purchase",
|
|
91
|
+
"per_review",
|
|
92
|
+
"per_referral_redeemed",
|
|
93
|
+
"birthday",
|
|
94
|
+
"signup_bonus",
|
|
95
|
+
"first_purchase",
|
|
96
|
+
"abandoned_cart_recovered",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
// Triggers that scale points by a caller-supplied unit count.
|
|
100
|
+
// per_dollar_spent multiplies points_per_unit by the order's dollar
|
|
101
|
+
// subtotal; every other trigger awards a flat points_per_unit.
|
|
102
|
+
var UNIT_TRIGGERS = Object.freeze({
|
|
103
|
+
per_dollar_spent: "dollars_spent",
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;
|
|
107
|
+
var TRIGGER_REF_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
108
|
+
var STATUS_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
109
|
+
|
|
110
|
+
var MAX_STATUS_LIST = 16;
|
|
111
|
+
var MAX_POINTS_PER_UNIT = 1000000; // 1M cap on a single multiplier
|
|
112
|
+
var MAX_MAX_PER_EVENT = 1000000000; // 1B cap on a per-event ceiling
|
|
113
|
+
|
|
114
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
115
|
+
var MAX_LIST_LIMIT = 500;
|
|
116
|
+
|
|
117
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
118
|
+
//
|
|
119
|
+
// Awards land in `loyalty_earn_log` keyed by `occurred_at`. The metrics
|
|
120
|
+
// rollup window scans by (rule_slug, occurred_at >= from AND <= to);
|
|
121
|
+
// two awards in the same millisecond would tie on the sort key and the
|
|
122
|
+
// `applyBatch` path issues many awards in a tight loop. The strict-
|
|
123
|
+
// monotonic clock guarantees distinct timestamps per call so ordering
|
|
124
|
+
// is deterministic without a tiebreaker column.
|
|
125
|
+
|
|
126
|
+
var _lastTs = 0;
|
|
127
|
+
function _now() {
|
|
128
|
+
var t = Date.now();
|
|
129
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
130
|
+
_lastTs = t;
|
|
131
|
+
return t;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- validators ---------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
function _slug(s, label) {
|
|
137
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
138
|
+
throw new TypeError("loyaltyEarnRules: " + (label || "slug") +
|
|
139
|
+
" must be lowercase alnum + dash, no leading/trailing dash, 1..100 chars");
|
|
140
|
+
}
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _trigger(s) {
|
|
145
|
+
if (typeof s !== "string" || TRIGGERS.indexOf(s) < 0) {
|
|
146
|
+
throw new TypeError("loyaltyEarnRules: trigger must be one of " + TRIGGERS.join(", "));
|
|
147
|
+
}
|
|
148
|
+
return s;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _pointsPerUnit(n) {
|
|
152
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_POINTS_PER_UNIT) {
|
|
153
|
+
throw new TypeError("loyaltyEarnRules: points_per_unit must be a positive integer <= " +
|
|
154
|
+
MAX_POINTS_PER_UNIT);
|
|
155
|
+
}
|
|
156
|
+
return n;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _maxPerEvent(n) {
|
|
160
|
+
if (n == null) return null;
|
|
161
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n <= 0 || n > MAX_MAX_PER_EVENT) {
|
|
162
|
+
throw new TypeError("loyaltyEarnRules: max_per_event must be a positive integer <= " +
|
|
163
|
+
MAX_MAX_PER_EVENT + " (or omitted)");
|
|
164
|
+
}
|
|
165
|
+
return n;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function _customerStatusIn(arr) {
|
|
169
|
+
if (arr == null) return null;
|
|
170
|
+
if (!Array.isArray(arr)) {
|
|
171
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in must be an array of status strings");
|
|
172
|
+
}
|
|
173
|
+
if (arr.length === 0) {
|
|
174
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in must be non-empty when provided");
|
|
175
|
+
}
|
|
176
|
+
if (arr.length > MAX_STATUS_LIST) {
|
|
177
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in must contain <= " +
|
|
178
|
+
MAX_STATUS_LIST + " entries");
|
|
179
|
+
}
|
|
180
|
+
var seen = Object.create(null);
|
|
181
|
+
var out = [];
|
|
182
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
183
|
+
var s = arr[i];
|
|
184
|
+
if (typeof s !== "string" || !STATUS_RE.test(s)) {
|
|
185
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
|
|
186
|
+
"] must be lowercase alnum / underscore / dash, 1..32 chars");
|
|
187
|
+
}
|
|
188
|
+
if (seen[s]) {
|
|
189
|
+
throw new TypeError("loyaltyEarnRules: customer_status_in[" + i +
|
|
190
|
+
"] duplicates a previous entry");
|
|
191
|
+
}
|
|
192
|
+
seen[s] = true;
|
|
193
|
+
out.push(s);
|
|
194
|
+
}
|
|
195
|
+
return out;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function _uuid(s, label) {
|
|
199
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
200
|
+
catch (e) { throw new TypeError("loyaltyEarnRules: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function _triggerEventRef(s) {
|
|
204
|
+
if (typeof s !== "string" || !TRIGGER_REF_RE.test(s)) {
|
|
205
|
+
throw new TypeError("loyaltyEarnRules: trigger_event_ref must match /^[A-Za-z0-9][A-Za-z0-9._:-]*$/ (1..128 chars)");
|
|
206
|
+
}
|
|
207
|
+
return s;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _statusOpt(s) {
|
|
211
|
+
if (s == null) return null;
|
|
212
|
+
if (typeof s !== "string" || !STATUS_RE.test(s)) {
|
|
213
|
+
throw new TypeError("loyaltyEarnRules: customer_status must be lowercase alnum / underscore / dash, 1..32 chars");
|
|
214
|
+
}
|
|
215
|
+
return s;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function _epochOpt(n, label) {
|
|
219
|
+
if (n == null) return null;
|
|
220
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
221
|
+
throw new TypeError("loyaltyEarnRules: " + label + " must be a non-negative integer (ms epoch) or null");
|
|
222
|
+
}
|
|
223
|
+
return n;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function _limit(n) {
|
|
227
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
228
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
229
|
+
throw new TypeError("loyaltyEarnRules: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
230
|
+
}
|
|
231
|
+
return n;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function _bool(v, label) {
|
|
235
|
+
if (typeof v !== "boolean") {
|
|
236
|
+
throw new TypeError("loyaltyEarnRules: " + label + " must be a boolean");
|
|
237
|
+
}
|
|
238
|
+
return v;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Pure compute — given a rule row + an event context, return the
|
|
242
|
+
// (points, reason) tuple WITHOUT touching storage. Exported via
|
|
243
|
+
// evaluateForEvent + reused inside awardForEvent so the math is
|
|
244
|
+
// single-sourced.
|
|
245
|
+
function _computePoints(rule, ctx) {
|
|
246
|
+
var unitField = UNIT_TRIGGERS[rule.trigger];
|
|
247
|
+
var units = 1;
|
|
248
|
+
if (unitField != null) {
|
|
249
|
+
var raw = ctx[unitField];
|
|
250
|
+
if (typeof raw !== "number" || !isFinite(raw) || raw < 0) {
|
|
251
|
+
return { points: 0, reason: unitField + " must be a non-negative finite number for trigger " + rule.trigger };
|
|
252
|
+
}
|
|
253
|
+
units = Math.floor(raw);
|
|
254
|
+
if (units <= 0) {
|
|
255
|
+
return { points: 0, reason: unitField + " floored to zero" };
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
var raw_points = rule.points_per_unit * units;
|
|
259
|
+
if (rule.max_per_event != null && raw_points > rule.max_per_event) {
|
|
260
|
+
return {
|
|
261
|
+
points: rule.max_per_event,
|
|
262
|
+
reason: "capped at max_per_event=" + rule.max_per_event +
|
|
263
|
+
" (uncapped would have been " + raw_points + ")",
|
|
264
|
+
capped: true,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return { points: raw_points, reason: "trigger=" + rule.trigger + " units=" + units, capped: false };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- factory ------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
function create(opts) {
|
|
273
|
+
opts = opts || {};
|
|
274
|
+
var query = opts.query;
|
|
275
|
+
if (!query) {
|
|
276
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
277
|
+
}
|
|
278
|
+
// Optional loyalty handle — when wired, awardForEvent calls
|
|
279
|
+
// loyalty.earn so the points land in the customer's balance + the
|
|
280
|
+
// loyalty transaction audit trail in one go. Absent, the primitive
|
|
281
|
+
// still writes the loyalty_earn_log breadcrumb but the operator is
|
|
282
|
+
// responsible for posting to the ledger separately.
|
|
283
|
+
var loyaltyHandle = opts.loyalty || null;
|
|
284
|
+
|
|
285
|
+
// ---- internal helpers -----------------------------------------------
|
|
286
|
+
|
|
287
|
+
function _decodeRule(row) {
|
|
288
|
+
if (!row) return null;
|
|
289
|
+
var statusList = null;
|
|
290
|
+
if (row.customer_status_in_json) {
|
|
291
|
+
try { statusList = JSON.parse(row.customer_status_in_json); }
|
|
292
|
+
catch (_e) { statusList = null; }
|
|
293
|
+
}
|
|
294
|
+
return {
|
|
295
|
+
slug: row.slug,
|
|
296
|
+
trigger: row.trigger,
|
|
297
|
+
points_per_unit: Number(row.points_per_unit),
|
|
298
|
+
max_per_event: row.max_per_event == null ? null : Number(row.max_per_event),
|
|
299
|
+
customer_status_in: statusList,
|
|
300
|
+
active: Number(row.active) === 1,
|
|
301
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
302
|
+
created_at: Number(row.created_at),
|
|
303
|
+
updated_at: Number(row.updated_at),
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function _ruleRow(slug) {
|
|
308
|
+
var r = await query("SELECT * FROM loyalty_earn_rules WHERE slug = ?1", [slug]);
|
|
309
|
+
return r.rows[0] || null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Status gate. If the rule restricts to a status list, the event's
|
|
313
|
+
// customer_status MUST appear in the list. NULL list means no
|
|
314
|
+
// restriction. Returns null when the event passes, or a reason
|
|
315
|
+
// string when it's filtered out.
|
|
316
|
+
function _statusFilter(rule, ctx) {
|
|
317
|
+
if (rule.customer_status_in == null) return null;
|
|
318
|
+
var status = ctx.customer_status;
|
|
319
|
+
if (status == null || rule.customer_status_in.indexOf(status) < 0) {
|
|
320
|
+
return "customer_status=" + JSON.stringify(status) +
|
|
321
|
+
" not in [" + rule.customer_status_in.join(", ") + "]";
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- defineRule -----------------------------------------------------
|
|
327
|
+
|
|
328
|
+
async function defineRule(input) {
|
|
329
|
+
if (!input || typeof input !== "object") {
|
|
330
|
+
throw new TypeError("loyaltyEarnRules.defineRule: input object required");
|
|
331
|
+
}
|
|
332
|
+
var slug = _slug(input.slug, "slug");
|
|
333
|
+
var trigger = _trigger(input.trigger);
|
|
334
|
+
var pointsPerUnit = _pointsPerUnit(input.points_per_unit);
|
|
335
|
+
var maxPerEvent = _maxPerEvent(input.max_per_event);
|
|
336
|
+
var customerStatusIn = _customerStatusIn(input.customer_status_in);
|
|
337
|
+
var active = input.active == null ? true : _bool(input.active, "active");
|
|
338
|
+
|
|
339
|
+
var existing = await _ruleRow(slug);
|
|
340
|
+
var ts = _now();
|
|
341
|
+
if (existing) {
|
|
342
|
+
if (existing.archived_at != null) {
|
|
343
|
+
throw new TypeError("loyaltyEarnRules.defineRule: rule " + JSON.stringify(slug) + " is archived");
|
|
344
|
+
}
|
|
345
|
+
await query(
|
|
346
|
+
"UPDATE loyalty_earn_rules " +
|
|
347
|
+
"SET trigger = ?1, points_per_unit = ?2, max_per_event = ?3, " +
|
|
348
|
+
"customer_status_in_json = ?4, active = ?5, updated_at = ?6 " +
|
|
349
|
+
"WHERE slug = ?7",
|
|
350
|
+
[trigger, pointsPerUnit, maxPerEvent,
|
|
351
|
+
customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
|
|
352
|
+
active ? 1 : 0, ts, slug],
|
|
353
|
+
);
|
|
354
|
+
} else {
|
|
355
|
+
await query(
|
|
356
|
+
"INSERT INTO loyalty_earn_rules " +
|
|
357
|
+
"(slug, trigger, points_per_unit, max_per_event, customer_status_in_json, " +
|
|
358
|
+
" active, archived_at, created_at, updated_at) " +
|
|
359
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
|
|
360
|
+
[slug, trigger, pointsPerUnit, maxPerEvent,
|
|
361
|
+
customerStatusIn == null ? null : JSON.stringify(customerStatusIn),
|
|
362
|
+
active ? 1 : 0, ts],
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
return _decodeRule(await _ruleRow(slug));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ---- getRule / listRules --------------------------------------------
|
|
369
|
+
|
|
370
|
+
async function getRule(slug) {
|
|
371
|
+
_slug(slug, "slug");
|
|
372
|
+
return _decodeRule(await _ruleRow(slug));
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function listRules(listOpts) {
|
|
376
|
+
listOpts = listOpts || {};
|
|
377
|
+
var activeOnly = listOpts.active_only == null ? false : _bool(listOpts.active_only, "active_only");
|
|
378
|
+
var limit = _limit(listOpts.limit);
|
|
379
|
+
var sql = "SELECT * FROM loyalty_earn_rules";
|
|
380
|
+
var params = [];
|
|
381
|
+
var idx = 1;
|
|
382
|
+
var where = [];
|
|
383
|
+
if (activeOnly) {
|
|
384
|
+
where.push("active = ?" + idx); params.push(1); idx += 1;
|
|
385
|
+
where.push("archived_at IS NULL");
|
|
386
|
+
}
|
|
387
|
+
if (listOpts.trigger != null) {
|
|
388
|
+
where.push("trigger = ?" + idx); params.push(_trigger(listOpts.trigger)); idx += 1;
|
|
389
|
+
}
|
|
390
|
+
if (where.length) sql += " WHERE " + where.join(" AND ");
|
|
391
|
+
sql += " ORDER BY slug ASC LIMIT ?" + idx;
|
|
392
|
+
params.push(limit);
|
|
393
|
+
var r = await query(sql, params);
|
|
394
|
+
var out = [];
|
|
395
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeRule(r.rows[i]));
|
|
396
|
+
return out;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---- updateRule -----------------------------------------------------
|
|
400
|
+
|
|
401
|
+
async function updateRule(slug, patch) {
|
|
402
|
+
_slug(slug, "slug");
|
|
403
|
+
if (!patch || typeof patch !== "object") {
|
|
404
|
+
throw new TypeError("loyaltyEarnRules.updateRule: patch object required");
|
|
405
|
+
}
|
|
406
|
+
var existing = await _ruleRow(slug);
|
|
407
|
+
if (!existing) return null;
|
|
408
|
+
if (existing.archived_at != null) {
|
|
409
|
+
throw new TypeError("loyaltyEarnRules.updateRule: rule " + JSON.stringify(slug) + " is archived");
|
|
410
|
+
}
|
|
411
|
+
var decoded = _decodeRule(existing);
|
|
412
|
+
|
|
413
|
+
var nextPoints = decoded.points_per_unit;
|
|
414
|
+
if (Object.prototype.hasOwnProperty.call(patch, "points_per_unit")) {
|
|
415
|
+
nextPoints = _pointsPerUnit(patch.points_per_unit);
|
|
416
|
+
}
|
|
417
|
+
var nextMax = decoded.max_per_event;
|
|
418
|
+
if (Object.prototype.hasOwnProperty.call(patch, "max_per_event")) {
|
|
419
|
+
nextMax = _maxPerEvent(patch.max_per_event);
|
|
420
|
+
}
|
|
421
|
+
var nextStatus = decoded.customer_status_in;
|
|
422
|
+
if (Object.prototype.hasOwnProperty.call(patch, "customer_status_in")) {
|
|
423
|
+
nextStatus = _customerStatusIn(patch.customer_status_in);
|
|
424
|
+
}
|
|
425
|
+
var nextActive = decoded.active;
|
|
426
|
+
if (Object.prototype.hasOwnProperty.call(patch, "active")) {
|
|
427
|
+
nextActive = _bool(patch.active, "active");
|
|
428
|
+
}
|
|
429
|
+
// trigger is immutable on update — operators that need a different
|
|
430
|
+
// trigger archive the rule and define a new one. Otherwise the
|
|
431
|
+
// metricsForRule history straddles two semantically distinct
|
|
432
|
+
// event spaces and the rollup becomes a lie.
|
|
433
|
+
if (Object.prototype.hasOwnProperty.call(patch, "trigger") && patch.trigger !== decoded.trigger) {
|
|
434
|
+
throw new TypeError("loyaltyEarnRules.updateRule: trigger is immutable — archive + define a new rule instead");
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
var ts = _now();
|
|
438
|
+
await query(
|
|
439
|
+
"UPDATE loyalty_earn_rules SET points_per_unit = ?1, max_per_event = ?2, " +
|
|
440
|
+
"customer_status_in_json = ?3, active = ?4, updated_at = ?5 WHERE slug = ?6",
|
|
441
|
+
[nextPoints, nextMax,
|
|
442
|
+
nextStatus == null ? null : JSON.stringify(nextStatus),
|
|
443
|
+
nextActive ? 1 : 0, ts, slug],
|
|
444
|
+
);
|
|
445
|
+
return _decodeRule(await _ruleRow(slug));
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---- archiveRule ----------------------------------------------------
|
|
449
|
+
|
|
450
|
+
async function archiveRule(slug) {
|
|
451
|
+
_slug(slug, "slug");
|
|
452
|
+
var ts = _now();
|
|
453
|
+
var r = await query(
|
|
454
|
+
"UPDATE loyalty_earn_rules SET archived_at = ?1, active = 0, updated_at = ?1 " +
|
|
455
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
456
|
+
[ts, slug],
|
|
457
|
+
);
|
|
458
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
459
|
+
var existing = await _ruleRow(slug);
|
|
460
|
+
if (!existing) return null;
|
|
461
|
+
return _decodeRule(existing);
|
|
462
|
+
}
|
|
463
|
+
return _decodeRule(await _ruleRow(slug));
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// ---- evaluateForEvent (dry-run) -------------------------------------
|
|
467
|
+
|
|
468
|
+
async function evaluateForEvent(input) {
|
|
469
|
+
if (!input || typeof input !== "object") {
|
|
470
|
+
throw new TypeError("loyaltyEarnRules.evaluateForEvent: input object required");
|
|
471
|
+
}
|
|
472
|
+
var trigger = _trigger(input.trigger);
|
|
473
|
+
_uuid(input.customer_id, "customer_id");
|
|
474
|
+
_statusOpt(input.customer_status);
|
|
475
|
+
|
|
476
|
+
// The slug-targeted path lets operators preview a single named
|
|
477
|
+
// rule even when several rules share the same trigger. Absent a
|
|
478
|
+
// slug, every active matching-trigger rule is evaluated; the
|
|
479
|
+
// primitive returns each rule's verdict (eligible / skipped /
|
|
480
|
+
// capped) so the operator-facing UI can render a per-rule
|
|
481
|
+
// breakdown.
|
|
482
|
+
var rules;
|
|
483
|
+
if (input.slug != null) {
|
|
484
|
+
var slug = _slug(input.slug, "slug");
|
|
485
|
+
var r = await _ruleRow(slug);
|
|
486
|
+
rules = r ? [r] : [];
|
|
487
|
+
} else {
|
|
488
|
+
var r2 = await query(
|
|
489
|
+
"SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
|
|
490
|
+
"ORDER BY slug ASC",
|
|
491
|
+
[trigger],
|
|
492
|
+
);
|
|
493
|
+
rules = r2.rows;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
var verdicts = [];
|
|
497
|
+
var totalPoints = 0;
|
|
498
|
+
for (var i = 0; i < rules.length; i += 1) {
|
|
499
|
+
var rule = _decodeRule(rules[i]);
|
|
500
|
+
if (rule.trigger !== trigger) {
|
|
501
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0,
|
|
502
|
+
reason: "rule.trigger=" + rule.trigger + " != requested " + trigger });
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (!rule.active || rule.archived_at != null) {
|
|
506
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0,
|
|
507
|
+
reason: "rule is inactive or archived" });
|
|
508
|
+
continue;
|
|
509
|
+
}
|
|
510
|
+
var statusReason = _statusFilter(rule, input);
|
|
511
|
+
if (statusReason) {
|
|
512
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: statusReason });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
var calc = _computePoints(rule, input);
|
|
516
|
+
if (calc.points <= 0) {
|
|
517
|
+
verdicts.push({ slug: rule.slug, eligible: false, points: 0, reason: calc.reason });
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
verdicts.push({
|
|
521
|
+
slug: rule.slug,
|
|
522
|
+
eligible: true,
|
|
523
|
+
points: calc.points,
|
|
524
|
+
reason: calc.reason,
|
|
525
|
+
capped: !!calc.capped,
|
|
526
|
+
});
|
|
527
|
+
totalPoints += calc.points;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
return {
|
|
531
|
+
trigger: trigger,
|
|
532
|
+
customer_id: input.customer_id,
|
|
533
|
+
total_points: totalPoints,
|
|
534
|
+
verdicts: verdicts,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---- awardForEvent --------------------------------------------------
|
|
539
|
+
|
|
540
|
+
async function awardForEvent(input) {
|
|
541
|
+
if (!input || typeof input !== "object") {
|
|
542
|
+
throw new TypeError("loyaltyEarnRules.awardForEvent: input object required");
|
|
543
|
+
}
|
|
544
|
+
var trigger = _trigger(input.trigger);
|
|
545
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
546
|
+
var triggerEventRef = _triggerEventRef(input.trigger_event_ref);
|
|
547
|
+
_statusOpt(input.customer_status);
|
|
548
|
+
|
|
549
|
+
var rules;
|
|
550
|
+
if (input.slug != null) {
|
|
551
|
+
var slug = _slug(input.slug, "slug");
|
|
552
|
+
var r = await _ruleRow(slug);
|
|
553
|
+
rules = r ? [r] : [];
|
|
554
|
+
} else {
|
|
555
|
+
var r2 = await query(
|
|
556
|
+
"SELECT * FROM loyalty_earn_rules WHERE trigger = ?1 AND active = 1 AND archived_at IS NULL " +
|
|
557
|
+
"ORDER BY slug ASC",
|
|
558
|
+
[trigger],
|
|
559
|
+
);
|
|
560
|
+
rules = r2.rows;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
var awarded = [];
|
|
564
|
+
var skipped = [];
|
|
565
|
+
var totalPts = 0;
|
|
566
|
+
for (var i = 0; i < rules.length; i += 1) {
|
|
567
|
+
var rule = _decodeRule(rules[i]);
|
|
568
|
+
if (rule.trigger !== trigger) {
|
|
569
|
+
skipped.push({ slug: rule.slug, reason: "rule.trigger != requested trigger" });
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!rule.active || rule.archived_at != null) {
|
|
573
|
+
skipped.push({ slug: rule.slug, reason: "rule is inactive or archived" });
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
var statusReason = _statusFilter(rule, input);
|
|
577
|
+
if (statusReason) {
|
|
578
|
+
skipped.push({ slug: rule.slug, reason: statusReason });
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
var calc = _computePoints(rule, input);
|
|
582
|
+
if (calc.points <= 0) {
|
|
583
|
+
skipped.push({ slug: rule.slug, reason: calc.reason });
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Dedup at the storage layer: the UNIQUE (rule_slug,
|
|
588
|
+
// customer_id, trigger_event_ref) collapses a retried award
|
|
589
|
+
// onto the existing row. SQLite's INSERT OR IGNORE is the
|
|
590
|
+
// cheapest portable shape — when 0 rows change we surface the
|
|
591
|
+
// dedup as a skipped reason rather than a hard error so a
|
|
592
|
+
// webhook retry produces a consistent observable result.
|
|
593
|
+
var logId = _b().uuid.v7();
|
|
594
|
+
var ts = _now();
|
|
595
|
+
var ins = await query(
|
|
596
|
+
"INSERT OR IGNORE INTO loyalty_earn_log " +
|
|
597
|
+
"(id, rule_slug, customer_id, points_awarded, trigger_event_ref, occurred_at) " +
|
|
598
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
599
|
+
[logId, rule.slug, customerId, calc.points, triggerEventRef, ts],
|
|
600
|
+
);
|
|
601
|
+
if (Number(ins.rowCount || 0) === 0) {
|
|
602
|
+
skipped.push({
|
|
603
|
+
slug: rule.slug,
|
|
604
|
+
reason: "duplicate trigger_event_ref — already awarded for this event",
|
|
605
|
+
});
|
|
606
|
+
continue;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Compose loyalty.earn when wired. Source is derived from the
|
|
610
|
+
// trigger name — loyalty's source validator demands lowercase
|
|
611
|
+
// alnum + `._-`, which the trigger enum already satisfies.
|
|
612
|
+
if (loyaltyHandle && typeof loyaltyHandle.earn === "function") {
|
|
613
|
+
try {
|
|
614
|
+
await loyaltyHandle.earn({
|
|
615
|
+
customer_id: customerId,
|
|
616
|
+
points: calc.points,
|
|
617
|
+
source: "earn-rule." + rule.slug,
|
|
618
|
+
notes: "trigger=" + trigger + " ref=" + triggerEventRef,
|
|
619
|
+
});
|
|
620
|
+
} catch (err) {
|
|
621
|
+
// Loyalty ledger failed AFTER the audit log wrote. Roll
|
|
622
|
+
// the audit row back so the next retry isn't dedup-skipped
|
|
623
|
+
// against a row that never made it to the ledger. The
|
|
624
|
+
// operator-facing failure carries the underlying loyalty
|
|
625
|
+
// error so debugging hits the root cause not the audit
|
|
626
|
+
// breadcrumb.
|
|
627
|
+
await query(
|
|
628
|
+
"DELETE FROM loyalty_earn_log WHERE id = ?1",
|
|
629
|
+
[logId],
|
|
630
|
+
);
|
|
631
|
+
throw err;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
awarded.push({
|
|
636
|
+
log_id: logId,
|
|
637
|
+
slug: rule.slug,
|
|
638
|
+
points: calc.points,
|
|
639
|
+
capped: !!calc.capped,
|
|
640
|
+
trigger_event_ref: triggerEventRef,
|
|
641
|
+
occurred_at: ts,
|
|
642
|
+
});
|
|
643
|
+
totalPts += calc.points;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return {
|
|
647
|
+
trigger: trigger,
|
|
648
|
+
customer_id: customerId,
|
|
649
|
+
total_points: totalPts,
|
|
650
|
+
awarded: awarded,
|
|
651
|
+
skipped: skipped,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// ---- metricsForRule -------------------------------------------------
|
|
656
|
+
|
|
657
|
+
async function metricsForRule(input) {
|
|
658
|
+
if (!input || typeof input !== "object") {
|
|
659
|
+
throw new TypeError("loyaltyEarnRules.metricsForRule: input object required");
|
|
660
|
+
}
|
|
661
|
+
var slug = _slug(input.slug, "slug");
|
|
662
|
+
var from = _epochOpt(input.from, "from");
|
|
663
|
+
var to = _epochOpt(input.to, "to");
|
|
664
|
+
if (from != null && to != null && from > to) {
|
|
665
|
+
throw new TypeError("loyaltyEarnRules.metricsForRule: from must be <= to");
|
|
666
|
+
}
|
|
667
|
+
var ruleRow = await _ruleRow(slug);
|
|
668
|
+
if (!ruleRow) return null;
|
|
669
|
+
|
|
670
|
+
var sql = "SELECT COUNT(*) AS award_count, COUNT(DISTINCT customer_id) AS unique_customers, " +
|
|
671
|
+
"COALESCE(SUM(points_awarded), 0) AS total_points, " +
|
|
672
|
+
"MIN(occurred_at) AS first_award, MAX(occurred_at) AS last_award " +
|
|
673
|
+
"FROM loyalty_earn_log WHERE rule_slug = ?1";
|
|
674
|
+
var params = [slug];
|
|
675
|
+
var idx = 2;
|
|
676
|
+
if (from != null) { sql += " AND occurred_at >= ?" + idx; params.push(from); idx += 1; }
|
|
677
|
+
if (to != null) { sql += " AND occurred_at <= ?" + idx; params.push(to); idx += 1; }
|
|
678
|
+
|
|
679
|
+
var r = await query(sql, params);
|
|
680
|
+
var row = r.rows[0] || { award_count: 0, unique_customers: 0, total_points: 0,
|
|
681
|
+
first_award: null, last_award: null };
|
|
682
|
+
return {
|
|
683
|
+
slug: slug,
|
|
684
|
+
trigger: ruleRow.trigger,
|
|
685
|
+
from: from,
|
|
686
|
+
to: to,
|
|
687
|
+
award_count: Number(row.award_count || 0),
|
|
688
|
+
unique_customers: Number(row.unique_customers || 0),
|
|
689
|
+
total_points: Number(row.total_points || 0),
|
|
690
|
+
first_award: row.first_award == null ? null : Number(row.first_award),
|
|
691
|
+
last_award: row.last_award == null ? null : Number(row.last_award),
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---- applyBatch -----------------------------------------------------
|
|
696
|
+
|
|
697
|
+
async function applyBatch(input) {
|
|
698
|
+
if (!input || typeof input !== "object") {
|
|
699
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: input object required");
|
|
700
|
+
}
|
|
701
|
+
if (!Array.isArray(input.events) || input.events.length === 0) {
|
|
702
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: events must be a non-empty array");
|
|
703
|
+
}
|
|
704
|
+
if (input.events.length > 10000) {
|
|
705
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: events.length must be <= 10000");
|
|
706
|
+
}
|
|
707
|
+
// `rules` is an optional advisory hint — when supplied, the batch
|
|
708
|
+
// restricts to those rule slugs by passing through the per-event
|
|
709
|
+
// `slug` field. The primary path uses the per-event `slug` (when
|
|
710
|
+
// present) or `trigger` (when absent).
|
|
711
|
+
var ruleFilter = null;
|
|
712
|
+
if (input.rules != null) {
|
|
713
|
+
if (!Array.isArray(input.rules)) {
|
|
714
|
+
throw new TypeError("loyaltyEarnRules.applyBatch: rules must be an array of slugs when provided");
|
|
715
|
+
}
|
|
716
|
+
ruleFilter = Object.create(null);
|
|
717
|
+
for (var ri = 0; ri < input.rules.length; ri += 1) {
|
|
718
|
+
ruleFilter[_slug(input.rules[ri], "rules[" + ri + "]")] = true;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
var awarded = [];
|
|
723
|
+
var skipped = [];
|
|
724
|
+
var failed = [];
|
|
725
|
+
var totalPts = 0;
|
|
726
|
+
|
|
727
|
+
for (var i = 0; i < input.events.length; i += 1) {
|
|
728
|
+
var ev = input.events[i];
|
|
729
|
+
if (!ev || typeof ev !== "object") {
|
|
730
|
+
failed.push({ index: i, reason: "event must be an object" });
|
|
731
|
+
continue;
|
|
732
|
+
}
|
|
733
|
+
if (ruleFilter != null && ev.slug != null && !ruleFilter[ev.slug]) {
|
|
734
|
+
skipped.push({ index: i, slug: ev.slug, reason: "slug not in rules filter" });
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
try {
|
|
738
|
+
var result = await awardForEvent(ev);
|
|
739
|
+
for (var a = 0; a < result.awarded.length; a += 1) {
|
|
740
|
+
awarded.push({ index: i, award: result.awarded[a] });
|
|
741
|
+
totalPts += result.awarded[a].points;
|
|
742
|
+
}
|
|
743
|
+
for (var s = 0; s < result.skipped.length; s += 1) {
|
|
744
|
+
skipped.push({ index: i, slug: result.skipped[s].slug, reason: result.skipped[s].reason });
|
|
745
|
+
}
|
|
746
|
+
} catch (err) {
|
|
747
|
+
failed.push({ index: i, reason: err && err.message ? err.message : String(err) });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
total_events: input.events.length,
|
|
753
|
+
total_points: totalPts,
|
|
754
|
+
awarded: awarded,
|
|
755
|
+
skipped: skipped,
|
|
756
|
+
failed: failed,
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return {
|
|
761
|
+
TRIGGERS: TRIGGERS.slice(),
|
|
762
|
+
UNIT_TRIGGERS: Object.assign({}, UNIT_TRIGGERS),
|
|
763
|
+
MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
|
|
764
|
+
MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
|
|
765
|
+
MAX_STATUS_LIST: MAX_STATUS_LIST,
|
|
766
|
+
|
|
767
|
+
defineRule: defineRule,
|
|
768
|
+
getRule: getRule,
|
|
769
|
+
listRules: listRules,
|
|
770
|
+
updateRule: updateRule,
|
|
771
|
+
archiveRule: archiveRule,
|
|
772
|
+
evaluateForEvent: evaluateForEvent,
|
|
773
|
+
awardForEvent: awardForEvent,
|
|
774
|
+
metricsForRule: metricsForRule,
|
|
775
|
+
applyBatch: applyBatch,
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
module.exports = {
|
|
780
|
+
create: create,
|
|
781
|
+
TRIGGERS: TRIGGERS,
|
|
782
|
+
UNIT_TRIGGERS: UNIT_TRIGGERS,
|
|
783
|
+
MAX_POINTS_PER_UNIT: MAX_POINTS_PER_UNIT,
|
|
784
|
+
MAX_MAX_PER_EVENT: MAX_MAX_PER_EVENT,
|
|
785
|
+
MAX_STATUS_LIST: MAX_STATUS_LIST,
|
|
786
|
+
};
|