@blamejs/blamejs-shop 0.0.57 → 0.0.59
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 +4 -0
- package/lib/affiliates.js +1025 -0
- package/lib/collections.js +916 -0
- package/lib/customer-segments.js +817 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +16 -0
- package/lib/mailing-audiences.js +855 -0
- package/lib/order-timeline.js +1073 -0
- package/lib/promo-banners.js +726 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/recently-viewed.js +511 -0
- package/lib/return-labels.js +477 -0
- package/lib/sales-reports.js +843 -0
- package/lib/search-synonyms.js +792 -0
- package/lib/shipping-labels.js +603 -0
- package/lib/stock-alerts.js +563 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
|
@@ -0,0 +1,817 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerSegments
|
|
4
|
+
* @title Customer segments — RFM-style operator-defined groupings
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operators define named segments — "VIPs", "lapsed-90d", "high-
|
|
8
|
+
* value-new", "high-refund-rate" — as a bag of rule predicates
|
|
9
|
+
* ANDed together. The primitive evaluates the segment against the
|
|
10
|
+
* shop's order history (orders + return_authorizations) and
|
|
11
|
+
* returns the matching customer ids for targeted campaigns
|
|
12
|
+
* (newsletter sends, retention emails, loyalty bonuses).
|
|
13
|
+
*
|
|
14
|
+
* Surface:
|
|
15
|
+
*
|
|
16
|
+
* - defineSegment({ slug, title, description?, rules })
|
|
17
|
+
* Create a new segment. `slug` is the stable operator-facing
|
|
18
|
+
* handle (UNIQUE). `rules` is an AND-composed predicate bag —
|
|
19
|
+
* every predicate must hold for a customer to land in the
|
|
20
|
+
* segment. Unknown rule keys are refused at define time so a
|
|
21
|
+
* typo doesn't silently produce an empty segment.
|
|
22
|
+
*
|
|
23
|
+
* Supported rule keys:
|
|
24
|
+
*
|
|
25
|
+
* recency_days_max — last paid order ≤ N days ago
|
|
26
|
+
* recency_days_min — last paid order ≥ N days ago
|
|
27
|
+
* (the "lapsed" half of RFM)
|
|
28
|
+
* frequency_orders_min — count of paid orders ≥ N
|
|
29
|
+
* (lifetime; combine with
|
|
30
|
+
* recency_days_max for
|
|
31
|
+
* "frequent recent buyer")
|
|
32
|
+
* lifetime_orders_min — alias of frequency_orders_min
|
|
33
|
+
* kept for spec parity
|
|
34
|
+
* lifetime_orders_max — count of paid orders ≤ N
|
|
35
|
+
* (used to gate "new" buyers)
|
|
36
|
+
* monetary_minor_min — lifetime gross_total_minor ≥ N
|
|
37
|
+
* monetary_minor_max — lifetime gross_total_minor ≤ N
|
|
38
|
+
* aov_minor_min — avg-order-value ≥ N
|
|
39
|
+
* refund_rate_bps_max — refunds / orders in bps ≤ N
|
|
40
|
+
* (10000 bps = 100%); customers
|
|
41
|
+
* with zero orders are excluded
|
|
42
|
+
* from this gate (no division)
|
|
43
|
+
* refund_rate_bps_min — refunds / orders in bps ≥ N
|
|
44
|
+
* last_order_status_in — last paid order's status is in
|
|
45
|
+
* the supplied list (e.g.
|
|
46
|
+
* ["refunded"] catches
|
|
47
|
+
* post-refund customers)
|
|
48
|
+
* country_in — last paid order's ship-to
|
|
49
|
+
* country is in the supplied
|
|
50
|
+
* 2-letter ISO list
|
|
51
|
+
* currency_in — last paid order's currency in
|
|
52
|
+
* list (3-letter ISO)
|
|
53
|
+
*
|
|
54
|
+
* - evaluate(slug, { limit?, cursor? })
|
|
55
|
+
* Returns matching `customer_id`s from the membership cache,
|
|
56
|
+
* paginated via b.pagination HMAC cursor. Reads only — call
|
|
57
|
+
* `recompute()` to refresh the cache.
|
|
58
|
+
*
|
|
59
|
+
* - recompute({ slugs? })
|
|
60
|
+
* Operator scheduler entry point. Re-evaluates every active
|
|
61
|
+
* segment (or the supplied subset) against the live order /
|
|
62
|
+
* return tables and rewrites `customer_segment_membership`.
|
|
63
|
+
* Archived segments are skipped (their cache stays empty).
|
|
64
|
+
*
|
|
65
|
+
* - segmentsForCustomer(customer_id)
|
|
66
|
+
* Returns the active segments the customer currently sits in.
|
|
67
|
+
*
|
|
68
|
+
* - listSegments({ include_archived? })
|
|
69
|
+
* Enumerate every defined segment.
|
|
70
|
+
*
|
|
71
|
+
* - update(slug, patch)
|
|
72
|
+
* Mutates title / description / rules. Does NOT auto-recompute
|
|
73
|
+
* — the next scheduler tick will pick up the change.
|
|
74
|
+
*
|
|
75
|
+
* - archive(slug) / unarchive(slug)
|
|
76
|
+
* Soft-delete. Archive clears the cache; unarchive marks the
|
|
77
|
+
* segment for the next recompute.
|
|
78
|
+
*
|
|
79
|
+
* - stats(slug)
|
|
80
|
+
* { member_count, avg_lifetime_minor, last_recomputed_at }.
|
|
81
|
+
*
|
|
82
|
+
* Composition:
|
|
83
|
+
*
|
|
84
|
+
* - b.guardUuid — every customer_id / segment_id is shape-
|
|
85
|
+
* checked at the entry point.
|
|
86
|
+
* - b.uuid.v7 — segment row ids (also lexicographically
|
|
87
|
+
* sortable, but the primary sort key is
|
|
88
|
+
* customer_id within a segment).
|
|
89
|
+
* - b.pagination — HMAC-tagged cursor on (customer_id ASC)
|
|
90
|
+
* for `evaluate` so an operator can't
|
|
91
|
+
* hand-craft a cursor to skip past a hidden
|
|
92
|
+
* row or replay across deployments.
|
|
93
|
+
*
|
|
94
|
+
* Storage:
|
|
95
|
+
*
|
|
96
|
+
* - customer_segments + customer_segment_membership
|
|
97
|
+
* (migration 0049_customer_segments.sql).
|
|
98
|
+
*
|
|
99
|
+
* Cache semantics:
|
|
100
|
+
*
|
|
101
|
+
* The membership table is a denormalized cache. Newly defined or
|
|
102
|
+
* updated segments don't appear in `evaluate()` output until the
|
|
103
|
+
* next `recompute()` — by design, since campaign sends usually
|
|
104
|
+
* run off a snapshot. Archive clears the cache immediately so a
|
|
105
|
+
* cancelled segment doesn't leak into a campaign mid-send.
|
|
106
|
+
*
|
|
107
|
+
* @primitive customerSegments
|
|
108
|
+
* @related b.guardUuid, b.pagination, b.uuid
|
|
109
|
+
*/
|
|
110
|
+
|
|
111
|
+
var bShop;
|
|
112
|
+
function _b() {
|
|
113
|
+
if (!bShop) bShop = require("./index");
|
|
114
|
+
return bShop.framework;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
var DEFAULT_LIMIT = 100;
|
|
118
|
+
var MAX_LIMIT = 1000;
|
|
119
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
|
|
120
|
+
var MAX_TITLE_LEN = 200;
|
|
121
|
+
var MAX_DESC_LEN = 1000;
|
|
122
|
+
var MAX_LIST_LEN = 64; // country_in / last_order_status_in / currency_in caps
|
|
123
|
+
var ORDER_KEY = ["customer_id:asc"];
|
|
124
|
+
|
|
125
|
+
var KNOWN_RULE_KEYS = Object.freeze([
|
|
126
|
+
"recency_days_max",
|
|
127
|
+
"recency_days_min",
|
|
128
|
+
"frequency_orders_min",
|
|
129
|
+
"lifetime_orders_min",
|
|
130
|
+
"lifetime_orders_max",
|
|
131
|
+
"monetary_minor_min",
|
|
132
|
+
"monetary_minor_max",
|
|
133
|
+
"aov_minor_min",
|
|
134
|
+
"refund_rate_bps_max",
|
|
135
|
+
"refund_rate_bps_min",
|
|
136
|
+
"last_order_status_in",
|
|
137
|
+
"country_in",
|
|
138
|
+
"currency_in",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
var ALLOWED_ORDER_STATUSES = Object.freeze([
|
|
142
|
+
"pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled",
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
// ---- validators ---------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
function _uuid(s, label) {
|
|
148
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
149
|
+
catch (e) { throw new TypeError("customer-segments: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _slug(s) {
|
|
153
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
154
|
+
throw new TypeError("customer-segments: slug must match /^[a-z0-9][a-z0-9_-]*[a-z0-9]?$/ (≤ 64 chars)");
|
|
155
|
+
}
|
|
156
|
+
return s;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _title(s) {
|
|
160
|
+
if (typeof s !== "string" || !s.length) {
|
|
161
|
+
throw new TypeError("customer-segments: title must be a non-empty string");
|
|
162
|
+
}
|
|
163
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
164
|
+
throw new TypeError("customer-segments: title must be ≤ " + MAX_TITLE_LEN + " chars");
|
|
165
|
+
}
|
|
166
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
167
|
+
throw new TypeError("customer-segments: title must not contain control bytes");
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _description(s) {
|
|
173
|
+
if (s == null || s === "") return "";
|
|
174
|
+
if (typeof s !== "string") {
|
|
175
|
+
throw new TypeError("customer-segments: description must be a string");
|
|
176
|
+
}
|
|
177
|
+
if (s.length > MAX_DESC_LEN) {
|
|
178
|
+
throw new TypeError("customer-segments: description must be ≤ " + MAX_DESC_LEN + " chars");
|
|
179
|
+
}
|
|
180
|
+
if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
|
|
181
|
+
throw new TypeError("customer-segments: description must not contain control bytes");
|
|
182
|
+
}
|
|
183
|
+
return s;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _nonNegInt(n, label) {
|
|
187
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
188
|
+
throw new TypeError("customer-segments: " + label + " must be a non-negative integer");
|
|
189
|
+
}
|
|
190
|
+
return n;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _bps(n, label) {
|
|
194
|
+
_nonNegInt(n, label);
|
|
195
|
+
if (n > 10000) {
|
|
196
|
+
throw new TypeError("customer-segments: " + label + " must be ≤ 10000 (basis points)");
|
|
197
|
+
}
|
|
198
|
+
return n;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _stringList(arr, label, validator) {
|
|
202
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
203
|
+
throw new TypeError("customer-segments: " + label + " must be a non-empty array");
|
|
204
|
+
}
|
|
205
|
+
if (arr.length > MAX_LIST_LEN) {
|
|
206
|
+
throw new TypeError("customer-segments: " + label + " must contain ≤ " + MAX_LIST_LEN + " entries");
|
|
207
|
+
}
|
|
208
|
+
var seen = {};
|
|
209
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
210
|
+
var v = arr[i];
|
|
211
|
+
if (typeof v !== "string") {
|
|
212
|
+
throw new TypeError("customer-segments: " + label + "[" + i + "] must be a string");
|
|
213
|
+
}
|
|
214
|
+
if (validator) validator(v, label + "[" + i + "]");
|
|
215
|
+
if (seen[v]) {
|
|
216
|
+
throw new TypeError("customer-segments: " + label + " has duplicate entry " + JSON.stringify(v));
|
|
217
|
+
}
|
|
218
|
+
seen[v] = true;
|
|
219
|
+
}
|
|
220
|
+
return arr.slice();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _countryCode(v, label) {
|
|
224
|
+
if (!/^[A-Z]{2}$/.test(v)) {
|
|
225
|
+
throw new TypeError("customer-segments: " + label + " must be a 2-letter ISO 3166-1 country code");
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function _currencyCode(v, label) {
|
|
230
|
+
if (!/^[A-Z]{3}$/.test(v)) {
|
|
231
|
+
throw new TypeError("customer-segments: " + label + " must be a 3-letter ISO 4217 currency code");
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function _statusValue(v, label) {
|
|
236
|
+
if (ALLOWED_ORDER_STATUSES.indexOf(v) === -1) {
|
|
237
|
+
throw new TypeError("customer-segments: " + label + " must be one of " + ALLOWED_ORDER_STATUSES.join(", "));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function _validateRules(rules) {
|
|
242
|
+
if (rules == null || typeof rules !== "object" || Array.isArray(rules)) {
|
|
243
|
+
throw new TypeError("customer-segments: rules must be a plain object");
|
|
244
|
+
}
|
|
245
|
+
var keys = Object.keys(rules);
|
|
246
|
+
if (keys.length === 0) {
|
|
247
|
+
throw new TypeError("customer-segments: rules must contain at least one predicate");
|
|
248
|
+
}
|
|
249
|
+
var out = {};
|
|
250
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
251
|
+
var k = keys[i];
|
|
252
|
+
if (KNOWN_RULE_KEYS.indexOf(k) === -1) {
|
|
253
|
+
throw new TypeError("customer-segments: unknown rule key " + JSON.stringify(k) +
|
|
254
|
+
" (allowed: " + KNOWN_RULE_KEYS.join(", ") + ")");
|
|
255
|
+
}
|
|
256
|
+
var v = rules[k];
|
|
257
|
+
switch (k) {
|
|
258
|
+
case "recency_days_max":
|
|
259
|
+
case "recency_days_min":
|
|
260
|
+
case "frequency_orders_min":
|
|
261
|
+
case "lifetime_orders_min":
|
|
262
|
+
case "lifetime_orders_max":
|
|
263
|
+
case "monetary_minor_min":
|
|
264
|
+
case "monetary_minor_max":
|
|
265
|
+
case "aov_minor_min":
|
|
266
|
+
_nonNegInt(v, "rules." + k);
|
|
267
|
+
out[k] = v;
|
|
268
|
+
break;
|
|
269
|
+
case "refund_rate_bps_max":
|
|
270
|
+
case "refund_rate_bps_min":
|
|
271
|
+
_bps(v, "rules." + k);
|
|
272
|
+
out[k] = v;
|
|
273
|
+
break;
|
|
274
|
+
case "last_order_status_in":
|
|
275
|
+
out[k] = _stringList(v, "rules." + k, _statusValue);
|
|
276
|
+
break;
|
|
277
|
+
case "country_in":
|
|
278
|
+
out[k] = _stringList(v, "rules." + k, _countryCode);
|
|
279
|
+
break;
|
|
280
|
+
case "currency_in":
|
|
281
|
+
out[k] = _stringList(v, "rules." + k, _currencyCode);
|
|
282
|
+
break;
|
|
283
|
+
default:
|
|
284
|
+
// Unreachable — KNOWN_RULE_KEYS gate above catches anything
|
|
285
|
+
// else. Falling through here would silently drop a rule, so
|
|
286
|
+
// we throw to surface the missing branch in development.
|
|
287
|
+
throw new TypeError("customer-segments: rule key " + JSON.stringify(k) + " has no validator");
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
// Coherence: min ≤ max where both are set.
|
|
291
|
+
if (out.lifetime_orders_min != null && out.lifetime_orders_max != null
|
|
292
|
+
&& out.lifetime_orders_min > out.lifetime_orders_max) {
|
|
293
|
+
throw new TypeError("customer-segments: rules.lifetime_orders_min must be ≤ lifetime_orders_max");
|
|
294
|
+
}
|
|
295
|
+
if (out.monetary_minor_min != null && out.monetary_minor_max != null
|
|
296
|
+
&& out.monetary_minor_min > out.monetary_minor_max) {
|
|
297
|
+
throw new TypeError("customer-segments: rules.monetary_minor_min must be ≤ monetary_minor_max");
|
|
298
|
+
}
|
|
299
|
+
if (out.refund_rate_bps_min != null && out.refund_rate_bps_max != null
|
|
300
|
+
&& out.refund_rate_bps_min > out.refund_rate_bps_max) {
|
|
301
|
+
throw new TypeError("customer-segments: rules.refund_rate_bps_min must be ≤ refund_rate_bps_max");
|
|
302
|
+
}
|
|
303
|
+
if (out.recency_days_min != null && out.recency_days_max != null
|
|
304
|
+
&& out.recency_days_min > out.recency_days_max) {
|
|
305
|
+
throw new TypeError("customer-segments: rules.recency_days_min must be ≤ recency_days_max");
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function _limit(n, label) {
|
|
311
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
312
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
|
|
313
|
+
throw new TypeError("customer-segments: " + label + " must be an integer in [1, " + MAX_LIMIT + "]");
|
|
314
|
+
}
|
|
315
|
+
return n;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function _now() { return Date.now(); }
|
|
319
|
+
|
|
320
|
+
// ---- factory ------------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
function create(opts) {
|
|
323
|
+
opts = opts || {};
|
|
324
|
+
var query = opts.query;
|
|
325
|
+
if (!query) {
|
|
326
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Pagination cursors for evaluate() are HMAC-tagged via b.pagination
|
|
330
|
+
// so an operator can't hand-craft one to skip past a hidden row or
|
|
331
|
+
// replay across deployments. The secret defaults to a dev-only
|
|
332
|
+
// placeholder so the primitive boots in tests; production deploys
|
|
333
|
+
// supply a derived value (typically b.crypto.namespaceHash(
|
|
334
|
+
// "customer-segments-cursor", D1_BRIDGE_SECRET)).
|
|
335
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
336
|
+
if (process.env.NODE_ENV === "production") {
|
|
337
|
+
throw new Error("customer-segments.create: opts.cursorSecret is required in production");
|
|
338
|
+
}
|
|
339
|
+
opts.cursorSecret = "customer-segments-cursor-secret-dev-only";
|
|
340
|
+
}
|
|
341
|
+
var cursorSecret = opts.cursorSecret;
|
|
342
|
+
|
|
343
|
+
async function _readSegment(slug) {
|
|
344
|
+
var r = await query(
|
|
345
|
+
"SELECT id, slug, title, description, rules_json, archived_at, " +
|
|
346
|
+
"last_recomputed_at, last_member_count, created_at, updated_at " +
|
|
347
|
+
"FROM customer_segments WHERE slug = ?1 LIMIT 1",
|
|
348
|
+
[slug],
|
|
349
|
+
);
|
|
350
|
+
return r.rows[0] || null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function _hydrate(row) {
|
|
354
|
+
if (!row) return null;
|
|
355
|
+
var rules;
|
|
356
|
+
try { rules = JSON.parse(row.rules_json); }
|
|
357
|
+
catch (_e) { rules = {}; }
|
|
358
|
+
return {
|
|
359
|
+
id: row.id,
|
|
360
|
+
slug: row.slug,
|
|
361
|
+
title: row.title,
|
|
362
|
+
description: row.description,
|
|
363
|
+
rules: rules,
|
|
364
|
+
archived_at: row.archived_at,
|
|
365
|
+
last_recomputed_at: row.last_recomputed_at,
|
|
366
|
+
last_member_count: row.last_member_count,
|
|
367
|
+
created_at: row.created_at,
|
|
368
|
+
updated_at: row.updated_at,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Build the customer aggregate row set that segment rules predicate
|
|
373
|
+
// against. One row per customer_id observed in the orders table.
|
|
374
|
+
// Cancelled orders are excluded entirely (matches the analytics +
|
|
375
|
+
// sales-reports policy). Refund rate is computed from refunded-
|
|
376
|
+
// status orders, not from return_authorizations directly — an order
|
|
377
|
+
// moves to status='refunded' when its refund completes, which is
|
|
378
|
+
// the same FSM gate the operator dashboards use.
|
|
379
|
+
async function _customerAggregates(nowTs) {
|
|
380
|
+
var r = await query(
|
|
381
|
+
"SELECT customer_id, " +
|
|
382
|
+
" COUNT(*) AS order_count, " +
|
|
383
|
+
" SUM(grand_total_minor) AS gross_minor, " +
|
|
384
|
+
" MAX(created_at) AS last_order_at, " +
|
|
385
|
+
" SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END) AS refunded_count " +
|
|
386
|
+
"FROM orders " +
|
|
387
|
+
"WHERE customer_id IS NOT NULL AND status != 'cancelled' " +
|
|
388
|
+
"GROUP BY customer_id",
|
|
389
|
+
[],
|
|
390
|
+
);
|
|
391
|
+
var rows = r.rows;
|
|
392
|
+
var out = [];
|
|
393
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
394
|
+
var row = rows[i];
|
|
395
|
+
var orderCount = Number(row.order_count || 0);
|
|
396
|
+
var gross = Number(row.gross_minor || 0);
|
|
397
|
+
var refunded = Number(row.refunded_count || 0);
|
|
398
|
+
var lastAt = Number(row.last_order_at || 0);
|
|
399
|
+
var aov = orderCount > 0 ? Math.floor(gross / orderCount) : 0;
|
|
400
|
+
var refundBps = orderCount > 0 ? Math.floor((refunded * 10000) / orderCount) : 0;
|
|
401
|
+
var recencyDays = lastAt > 0 ? Math.floor((nowTs - lastAt) / (24 * 60 * 60 * 1000)) : null;
|
|
402
|
+
out.push({
|
|
403
|
+
customer_id: row.customer_id,
|
|
404
|
+
order_count: orderCount,
|
|
405
|
+
gross_minor: gross,
|
|
406
|
+
aov_minor: aov,
|
|
407
|
+
last_order_at: lastAt,
|
|
408
|
+
recency_days: recencyDays,
|
|
409
|
+
refunded_count: refunded,
|
|
410
|
+
refund_rate_bps: refundBps,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// Per-customer last-paid-order context (status, country, currency)
|
|
417
|
+
// needed for the last_order_status_in / country_in / currency_in
|
|
418
|
+
// predicates. Skipping cancelled orders so a cancelled last order
|
|
419
|
+
// doesn't surface as the "current" state.
|
|
420
|
+
async function _lastOrderContext() {
|
|
421
|
+
var r = await query(
|
|
422
|
+
"SELECT o.customer_id, o.status, o.currency, o.ship_to_json, o.created_at " +
|
|
423
|
+
"FROM orders o " +
|
|
424
|
+
"WHERE o.customer_id IS NOT NULL AND o.status != 'cancelled' " +
|
|
425
|
+
" AND o.created_at = (" +
|
|
426
|
+
" SELECT MAX(o2.created_at) FROM orders o2 " +
|
|
427
|
+
" WHERE o2.customer_id = o.customer_id AND o2.status != 'cancelled'" +
|
|
428
|
+
" )",
|
|
429
|
+
[],
|
|
430
|
+
);
|
|
431
|
+
var byCustomer = {};
|
|
432
|
+
var rows = r.rows;
|
|
433
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
434
|
+
var row = rows[i];
|
|
435
|
+
// If two orders share the same created_at timestamp the inner
|
|
436
|
+
// MAX returns both — pick the first deterministically; the
|
|
437
|
+
// last-order predicates don't need cross-row ordering once
|
|
438
|
+
// we're at the max timestamp.
|
|
439
|
+
if (byCustomer[row.customer_id]) continue;
|
|
440
|
+
var country = null;
|
|
441
|
+
try {
|
|
442
|
+
var shipTo = JSON.parse(row.ship_to_json || "{}");
|
|
443
|
+
if (shipTo && typeof shipTo.country === "string") country = shipTo.country;
|
|
444
|
+
} catch (_e) { /* drop-silent — ship_to_json is operator-shaped */ }
|
|
445
|
+
byCustomer[row.customer_id] = {
|
|
446
|
+
status: row.status,
|
|
447
|
+
currency: row.currency,
|
|
448
|
+
country: country,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
return byCustomer;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Pure predicate evaluator. Returns true iff every rule in the
|
|
455
|
+
// segment matches the customer aggregate row. Unknown rule keys
|
|
456
|
+
// never reach this function — they're rejected at define time.
|
|
457
|
+
function _matches(rules, agg, ctx) {
|
|
458
|
+
if (rules.recency_days_max != null) {
|
|
459
|
+
if (agg.recency_days == null || agg.recency_days > rules.recency_days_max) return false;
|
|
460
|
+
}
|
|
461
|
+
if (rules.recency_days_min != null) {
|
|
462
|
+
if (agg.recency_days == null || agg.recency_days < rules.recency_days_min) return false;
|
|
463
|
+
}
|
|
464
|
+
if (rules.frequency_orders_min != null) {
|
|
465
|
+
if (agg.order_count < rules.frequency_orders_min) return false;
|
|
466
|
+
}
|
|
467
|
+
if (rules.lifetime_orders_min != null) {
|
|
468
|
+
if (agg.order_count < rules.lifetime_orders_min) return false;
|
|
469
|
+
}
|
|
470
|
+
if (rules.lifetime_orders_max != null) {
|
|
471
|
+
if (agg.order_count > rules.lifetime_orders_max) return false;
|
|
472
|
+
}
|
|
473
|
+
if (rules.monetary_minor_min != null) {
|
|
474
|
+
if (agg.gross_minor < rules.monetary_minor_min) return false;
|
|
475
|
+
}
|
|
476
|
+
if (rules.monetary_minor_max != null) {
|
|
477
|
+
if (agg.gross_minor > rules.monetary_minor_max) return false;
|
|
478
|
+
}
|
|
479
|
+
if (rules.aov_minor_min != null) {
|
|
480
|
+
if (agg.aov_minor < rules.aov_minor_min) return false;
|
|
481
|
+
}
|
|
482
|
+
if (rules.refund_rate_bps_max != null) {
|
|
483
|
+
// Customers with zero orders never reach this branch (the
|
|
484
|
+
// aggregate query filters them out). Refund rate is a true
|
|
485
|
+
// fraction once we have at least one order.
|
|
486
|
+
if (agg.refund_rate_bps > rules.refund_rate_bps_max) return false;
|
|
487
|
+
}
|
|
488
|
+
if (rules.refund_rate_bps_min != null) {
|
|
489
|
+
if (agg.refund_rate_bps < rules.refund_rate_bps_min) return false;
|
|
490
|
+
}
|
|
491
|
+
if (rules.last_order_status_in != null) {
|
|
492
|
+
if (!ctx || rules.last_order_status_in.indexOf(ctx.status) === -1) return false;
|
|
493
|
+
}
|
|
494
|
+
if (rules.country_in != null) {
|
|
495
|
+
if (!ctx || ctx.country == null || rules.country_in.indexOf(ctx.country) === -1) return false;
|
|
496
|
+
}
|
|
497
|
+
if (rules.currency_in != null) {
|
|
498
|
+
if (!ctx || rules.currency_in.indexOf(ctx.currency) === -1) return false;
|
|
499
|
+
}
|
|
500
|
+
return true;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Recompute the membership for one segment. Returns the count of
|
|
504
|
+
// matched customers. Caller owns the transactional shape: this
|
|
505
|
+
// helper assumes the prior cache row set has already been deleted.
|
|
506
|
+
async function _recomputeSegment(segment, aggregates, lastContext, ts) {
|
|
507
|
+
if (segment.archived_at != null) return 0;
|
|
508
|
+
var rules = JSON.parse(segment.rules_json);
|
|
509
|
+
var matched = 0;
|
|
510
|
+
for (var i = 0; i < aggregates.length; i += 1) {
|
|
511
|
+
var agg = aggregates[i];
|
|
512
|
+
var ctx = lastContext[agg.customer_id] || null;
|
|
513
|
+
if (!_matches(rules, agg, ctx)) continue;
|
|
514
|
+
await query(
|
|
515
|
+
"INSERT INTO customer_segment_membership (segment_id, customer_id, evaluated_at) " +
|
|
516
|
+
"VALUES (?1, ?2, ?3)",
|
|
517
|
+
[segment.id, agg.customer_id, ts],
|
|
518
|
+
);
|
|
519
|
+
matched += 1;
|
|
520
|
+
}
|
|
521
|
+
await query(
|
|
522
|
+
"UPDATE customer_segments SET last_recomputed_at = ?1, last_member_count = ?2, " +
|
|
523
|
+
"updated_at = ?1 WHERE id = ?3",
|
|
524
|
+
[ts, matched, segment.id],
|
|
525
|
+
);
|
|
526
|
+
return matched;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return {
|
|
530
|
+
KNOWN_RULE_KEYS: KNOWN_RULE_KEYS.slice(),
|
|
531
|
+
ALLOWED_ORDER_STATUSES: ALLOWED_ORDER_STATUSES.slice(),
|
|
532
|
+
DEFAULT_LIMIT: DEFAULT_LIMIT,
|
|
533
|
+
MAX_LIMIT: MAX_LIMIT,
|
|
534
|
+
|
|
535
|
+
defineSegment: async function (input) {
|
|
536
|
+
if (!input || typeof input !== "object") {
|
|
537
|
+
throw new TypeError("customer-segments.defineSegment: input object required");
|
|
538
|
+
}
|
|
539
|
+
var slug = _slug(input.slug);
|
|
540
|
+
var title = _title(input.title);
|
|
541
|
+
var description = _description(input.description);
|
|
542
|
+
var rules = _validateRules(input.rules);
|
|
543
|
+
|
|
544
|
+
var existing = await _readSegment(slug);
|
|
545
|
+
if (existing) {
|
|
546
|
+
var err = new Error("customer-segments.defineSegment: slug " + JSON.stringify(slug) + " already defined");
|
|
547
|
+
err.code = "CUSTOMER_SEGMENT_SLUG_EXISTS";
|
|
548
|
+
throw err;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
var id = _b().uuid.v7();
|
|
552
|
+
var ts = _now();
|
|
553
|
+
await query(
|
|
554
|
+
"INSERT INTO customer_segments " +
|
|
555
|
+
"(id, slug, title, description, rules_json, archived_at, " +
|
|
556
|
+
" last_recomputed_at, last_member_count, created_at, updated_at) " +
|
|
557
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, 0, ?6, ?6)",
|
|
558
|
+
[id, slug, title, description, JSON.stringify(rules), ts],
|
|
559
|
+
);
|
|
560
|
+
return _hydrate(await _readSegment(slug));
|
|
561
|
+
},
|
|
562
|
+
|
|
563
|
+
evaluate: async function (slug, evalOpts) {
|
|
564
|
+
_slug(slug);
|
|
565
|
+
evalOpts = evalOpts || {};
|
|
566
|
+
var limit = _limit(evalOpts.limit, "limit");
|
|
567
|
+
|
|
568
|
+
var seg = await _readSegment(slug);
|
|
569
|
+
if (!seg) {
|
|
570
|
+
throw new TypeError("customer-segments.evaluate: unknown slug " + JSON.stringify(slug));
|
|
571
|
+
}
|
|
572
|
+
if (seg.archived_at != null) {
|
|
573
|
+
// Archived segments evaluate to empty — we cleared the cache
|
|
574
|
+
// on archive(), but a defensive check guards against an
|
|
575
|
+
// operator manually inserting rows in the membership table.
|
|
576
|
+
return { customer_ids: [], next_cursor: null };
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
var cursorVal = null;
|
|
580
|
+
if (evalOpts.cursor != null) {
|
|
581
|
+
if (typeof evalOpts.cursor !== "string") {
|
|
582
|
+
throw new TypeError("customer-segments.evaluate: cursor must be an opaque string or null");
|
|
583
|
+
}
|
|
584
|
+
try {
|
|
585
|
+
var state = _b().pagination.decodeCursor(evalOpts.cursor, cursorSecret);
|
|
586
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(ORDER_KEY)) {
|
|
587
|
+
throw new TypeError("customer-segments.evaluate: cursor orderKey mismatch");
|
|
588
|
+
}
|
|
589
|
+
cursorVal = state.vals[0];
|
|
590
|
+
} catch (e) {
|
|
591
|
+
if (e instanceof TypeError) throw e;
|
|
592
|
+
throw new TypeError("customer-segments.evaluate: cursor — " + (e && e.message || "malformed"));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
var sql, params;
|
|
597
|
+
if (cursorVal != null) {
|
|
598
|
+
sql = "SELECT customer_id FROM customer_segment_membership " +
|
|
599
|
+
"WHERE segment_id = ?1 AND customer_id > ?2 " +
|
|
600
|
+
"ORDER BY customer_id ASC LIMIT ?3";
|
|
601
|
+
params = [seg.id, cursorVal, limit];
|
|
602
|
+
} else {
|
|
603
|
+
sql = "SELECT customer_id FROM customer_segment_membership " +
|
|
604
|
+
"WHERE segment_id = ?1 ORDER BY customer_id ASC LIMIT ?2";
|
|
605
|
+
params = [seg.id, limit];
|
|
606
|
+
}
|
|
607
|
+
var rows = (await query(sql, params)).rows;
|
|
608
|
+
var ids = [];
|
|
609
|
+
for (var i = 0; i < rows.length; i += 1) ids.push(rows[i].customer_id);
|
|
610
|
+
var nextCursor = null;
|
|
611
|
+
if (ids.length === limit) {
|
|
612
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
613
|
+
orderKey: ORDER_KEY,
|
|
614
|
+
vals: [ids[ids.length - 1]],
|
|
615
|
+
forward: true,
|
|
616
|
+
}, cursorSecret);
|
|
617
|
+
}
|
|
618
|
+
return { customer_ids: ids, next_cursor: nextCursor };
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
recompute: async function (recomputeOpts) {
|
|
622
|
+
recomputeOpts = recomputeOpts || {};
|
|
623
|
+
var slugFilter = null;
|
|
624
|
+
if (recomputeOpts.slugs != null) {
|
|
625
|
+
if (!Array.isArray(recomputeOpts.slugs)) {
|
|
626
|
+
throw new TypeError("customer-segments.recompute: slugs must be an array");
|
|
627
|
+
}
|
|
628
|
+
slugFilter = {};
|
|
629
|
+
for (var i = 0; i < recomputeOpts.slugs.length; i += 1) {
|
|
630
|
+
slugFilter[_slug(recomputeOpts.slugs[i])] = true;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
var ts = _now();
|
|
634
|
+
var segments = (await query(
|
|
635
|
+
"SELECT id, slug, title, description, rules_json, archived_at, " +
|
|
636
|
+
"last_recomputed_at, last_member_count, created_at, updated_at " +
|
|
637
|
+
"FROM customer_segments WHERE archived_at IS NULL",
|
|
638
|
+
[],
|
|
639
|
+
)).rows;
|
|
640
|
+
var aggregates = await _customerAggregates(ts);
|
|
641
|
+
var lastContext = await _lastOrderContext();
|
|
642
|
+
|
|
643
|
+
var report = { segments_evaluated: 0, total_members: 0, per_segment: {} };
|
|
644
|
+
for (var j = 0; j < segments.length; j += 1) {
|
|
645
|
+
var seg = segments[j];
|
|
646
|
+
if (slugFilter && !slugFilter[seg.slug]) continue;
|
|
647
|
+
await query(
|
|
648
|
+
"DELETE FROM customer_segment_membership WHERE segment_id = ?1",
|
|
649
|
+
[seg.id],
|
|
650
|
+
);
|
|
651
|
+
var count = await _recomputeSegment(seg, aggregates, lastContext, ts);
|
|
652
|
+
report.segments_evaluated += 1;
|
|
653
|
+
report.total_members += count;
|
|
654
|
+
report.per_segment[seg.slug] = count;
|
|
655
|
+
}
|
|
656
|
+
return report;
|
|
657
|
+
},
|
|
658
|
+
|
|
659
|
+
segmentsForCustomer: async function (customerId) {
|
|
660
|
+
_uuid(customerId, "customer_id");
|
|
661
|
+
var r = await query(
|
|
662
|
+
"SELECT cs.slug, cs.title, cs.description, cs.rules_json, cs.last_recomputed_at, " +
|
|
663
|
+
"csm.evaluated_at " +
|
|
664
|
+
"FROM customer_segment_membership csm " +
|
|
665
|
+
"JOIN customer_segments cs ON cs.id = csm.segment_id " +
|
|
666
|
+
"WHERE csm.customer_id = ?1 AND cs.archived_at IS NULL " +
|
|
667
|
+
"ORDER BY cs.slug ASC",
|
|
668
|
+
[customerId],
|
|
669
|
+
);
|
|
670
|
+
var rows = r.rows;
|
|
671
|
+
var out = [];
|
|
672
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
673
|
+
var row = rows[i];
|
|
674
|
+
var rules;
|
|
675
|
+
try { rules = JSON.parse(row.rules_json); }
|
|
676
|
+
catch (_e) { rules = {}; }
|
|
677
|
+
out.push({
|
|
678
|
+
slug: row.slug,
|
|
679
|
+
title: row.title,
|
|
680
|
+
description: row.description,
|
|
681
|
+
rules: rules,
|
|
682
|
+
last_recomputed_at: row.last_recomputed_at,
|
|
683
|
+
evaluated_at: row.evaluated_at,
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
return out;
|
|
687
|
+
},
|
|
688
|
+
|
|
689
|
+
listSegments: async function (listOpts) {
|
|
690
|
+
listOpts = listOpts || {};
|
|
691
|
+
var includeArchived = !!listOpts.include_archived;
|
|
692
|
+
var sql = "SELECT id, slug, title, description, rules_json, archived_at, " +
|
|
693
|
+
"last_recomputed_at, last_member_count, created_at, updated_at " +
|
|
694
|
+
"FROM customer_segments";
|
|
695
|
+
if (!includeArchived) sql += " WHERE archived_at IS NULL";
|
|
696
|
+
sql += " ORDER BY slug ASC";
|
|
697
|
+
var rows = (await query(sql, [])).rows;
|
|
698
|
+
var out = [];
|
|
699
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrate(rows[i]));
|
|
700
|
+
return out;
|
|
701
|
+
},
|
|
702
|
+
|
|
703
|
+
update: async function (slug, patch) {
|
|
704
|
+
_slug(slug);
|
|
705
|
+
if (!patch || typeof patch !== "object") {
|
|
706
|
+
throw new TypeError("customer-segments.update: patch object required");
|
|
707
|
+
}
|
|
708
|
+
var existing = await _readSegment(slug);
|
|
709
|
+
if (!existing) {
|
|
710
|
+
throw new TypeError("customer-segments.update: unknown slug " + JSON.stringify(slug));
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
var nextTitle = existing.title;
|
|
714
|
+
var nextDesc = existing.description;
|
|
715
|
+
var nextRules = existing.rules_json;
|
|
716
|
+
|
|
717
|
+
if (Object.prototype.hasOwnProperty.call(patch, "title")) {
|
|
718
|
+
nextTitle = _title(patch.title);
|
|
719
|
+
}
|
|
720
|
+
if (Object.prototype.hasOwnProperty.call(patch, "description")) {
|
|
721
|
+
nextDesc = _description(patch.description);
|
|
722
|
+
}
|
|
723
|
+
if (Object.prototype.hasOwnProperty.call(patch, "rules")) {
|
|
724
|
+
nextRules = JSON.stringify(_validateRules(patch.rules));
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
var ts = _now();
|
|
728
|
+
await query(
|
|
729
|
+
"UPDATE customer_segments SET title = ?1, description = ?2, rules_json = ?3, " +
|
|
730
|
+
"updated_at = ?4 WHERE id = ?5",
|
|
731
|
+
[nextTitle, nextDesc, nextRules, ts, existing.id],
|
|
732
|
+
);
|
|
733
|
+
return _hydrate(await _readSegment(slug));
|
|
734
|
+
},
|
|
735
|
+
|
|
736
|
+
archive: async function (slug) {
|
|
737
|
+
_slug(slug);
|
|
738
|
+
var existing = await _readSegment(slug);
|
|
739
|
+
if (!existing) {
|
|
740
|
+
throw new TypeError("customer-segments.archive: unknown slug " + JSON.stringify(slug));
|
|
741
|
+
}
|
|
742
|
+
if (existing.archived_at != null) {
|
|
743
|
+
return _hydrate(existing);
|
|
744
|
+
}
|
|
745
|
+
var ts = _now();
|
|
746
|
+
await query(
|
|
747
|
+
"UPDATE customer_segments SET archived_at = ?1, updated_at = ?1, last_member_count = 0 " +
|
|
748
|
+
"WHERE id = ?2",
|
|
749
|
+
[ts, existing.id],
|
|
750
|
+
);
|
|
751
|
+
// Clear the cache so a campaign mid-send doesn't pick up rows
|
|
752
|
+
// for an archived segment.
|
|
753
|
+
await query(
|
|
754
|
+
"DELETE FROM customer_segment_membership WHERE segment_id = ?1",
|
|
755
|
+
[existing.id],
|
|
756
|
+
);
|
|
757
|
+
return _hydrate(await _readSegment(slug));
|
|
758
|
+
},
|
|
759
|
+
|
|
760
|
+
unarchive: async function (slug) {
|
|
761
|
+
_slug(slug);
|
|
762
|
+
var existing = await _readSegment(slug);
|
|
763
|
+
if (!existing) {
|
|
764
|
+
throw new TypeError("customer-segments.unarchive: unknown slug " + JSON.stringify(slug));
|
|
765
|
+
}
|
|
766
|
+
if (existing.archived_at == null) {
|
|
767
|
+
return _hydrate(existing);
|
|
768
|
+
}
|
|
769
|
+
var ts = _now();
|
|
770
|
+
await query(
|
|
771
|
+
"UPDATE customer_segments SET archived_at = NULL, updated_at = ?1 WHERE id = ?2",
|
|
772
|
+
[ts, existing.id],
|
|
773
|
+
);
|
|
774
|
+
return _hydrate(await _readSegment(slug));
|
|
775
|
+
},
|
|
776
|
+
|
|
777
|
+
stats: async function (slug) {
|
|
778
|
+
_slug(slug);
|
|
779
|
+
var seg = await _readSegment(slug);
|
|
780
|
+
if (!seg) {
|
|
781
|
+
throw new TypeError("customer-segments.stats: unknown slug " + JSON.stringify(slug));
|
|
782
|
+
}
|
|
783
|
+
// Live member count from the cache so a count run mid-recompute
|
|
784
|
+
// reflects what's actually visible to evaluate(). The
|
|
785
|
+
// last_member_count column on the segment row records the
|
|
786
|
+
// value at last_recomputed_at — equal once the recompute
|
|
787
|
+
// settles.
|
|
788
|
+
var memberRow = (await query(
|
|
789
|
+
"SELECT COUNT(*) AS n FROM customer_segment_membership WHERE segment_id = ?1",
|
|
790
|
+
[seg.id],
|
|
791
|
+
)).rows[0] || {};
|
|
792
|
+
var memberCount = Number(memberRow.n || 0);
|
|
793
|
+
var avgRow = (await query(
|
|
794
|
+
"SELECT AVG(o.grand_total_minor * 1.0) AS avg_minor " +
|
|
795
|
+
"FROM customer_segment_membership csm " +
|
|
796
|
+
"JOIN orders o ON o.customer_id = csm.customer_id " +
|
|
797
|
+
"WHERE csm.segment_id = ?1 AND o.status != 'cancelled'",
|
|
798
|
+
[seg.id],
|
|
799
|
+
)).rows[0] || {};
|
|
800
|
+
var avgMinor = avgRow.avg_minor == null ? 0 : Math.round(Number(avgRow.avg_minor));
|
|
801
|
+
return {
|
|
802
|
+
slug: seg.slug,
|
|
803
|
+
member_count: memberCount,
|
|
804
|
+
avg_lifetime_minor: avgMinor,
|
|
805
|
+
last_recomputed_at: seg.last_recomputed_at,
|
|
806
|
+
};
|
|
807
|
+
},
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
module.exports = {
|
|
812
|
+
create: create,
|
|
813
|
+
KNOWN_RULE_KEYS: KNOWN_RULE_KEYS,
|
|
814
|
+
ALLOWED_ORDER_STATUSES: ALLOWED_ORDER_STATUSES,
|
|
815
|
+
DEFAULT_LIMIT: DEFAULT_LIMIT,
|
|
816
|
+
MAX_LIMIT: MAX_LIMIT,
|
|
817
|
+
};
|