@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.
@@ -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
+ };