@blamejs/blamejs-shop 0.0.66 → 0.0.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +36 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/loyalty-earn-rules.js +786 -0
  19. package/lib/operator-roles.js +768 -0
  20. package/lib/order-escalation.js +951 -0
  21. package/lib/order-ratings.js +495 -0
  22. package/lib/order-tags.js +944 -0
  23. package/lib/packing-slips.js +810 -0
  24. package/lib/pixel-events.js +995 -0
  25. package/lib/print-queue.js +681 -0
  26. package/lib/product-qa.js +749 -0
  27. package/lib/promo-bundles.js +835 -0
  28. package/lib/push-notifications.js +937 -0
  29. package/lib/refund-automation.js +853 -0
  30. package/lib/reorder-reminders.js +798 -0
  31. package/lib/robots-config.js +753 -0
  32. package/lib/seller-signup.js +1052 -0
  33. package/lib/sitemap-generator.js +717 -0
  34. package/lib/split-shipments.js +7 -1
  35. package/lib/subscription-gifts.js +710 -0
  36. package/lib/tax-cert-renewals.js +632 -0
  37. package/lib/tier-benefits.js +776 -0
  38. package/lib/vendor/MANIFEST.json +2 -2
  39. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  40. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  41. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  42. package/lib/vendor/blamejs/package.json +1 -1
  43. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  44. package/lib/wishlist-alerts.js +842 -0
  45. package/lib/wishlist-sharing.js +718 -0
  46. package/package.json +1 -1
@@ -0,0 +1,951 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.orderEscalation
4
+ * @title Order escalation — operator workflow for routing orders
5
+ * that need manual attention.
6
+ *
7
+ * @intro
8
+ * A rule names a condition bag (any subset of total / country /
9
+ * prior-order count / fraud-score / refund-pending /
10
+ * payment-method-kind). When `evaluateOrder` is handed an order
11
+ * snapshot, every active rule whose entire condition bag matches
12
+ * fires; the caller composes `evaluateOrder` with
13
+ * `recordEscalation` (typically one per matched rule) so the
14
+ * primitive doesn't double-write when the orchestrator wants a
15
+ * dry-run preview.
16
+ *
17
+ * On `recordEscalation`, the primitive:
18
+ * 1. Inserts an `order_escalations` row with `status='open'`.
19
+ * 2. Stamps every `auto_tags` entry on the order via the
20
+ * operator-supplied `orderTags.applyTag(order_id, tag)` stub.
21
+ * 3. Dispatches the rule's `notification_template` through the
22
+ * operator-supplied `notifications.enqueue(...)` stub when
23
+ * both the template and the `assigned_operator` are present.
24
+ *
25
+ * The 3-state FSM on the escalation row is:
26
+ * open → resolved (resolveEscalation)
27
+ * open → false_positive (markAsFalsePositive)
28
+ *
29
+ * Re-resolving / re-flagging a non-open row is refused with
30
+ * `not in open state` — operators reopen by issuing a fresh
31
+ * escalation (this primitive is not a ticket tracker).
32
+ *
33
+ * Composes:
34
+ * - b.guardUuid — UUID-shape validation
35
+ * - b.uuid.v7 — escalation row id
36
+ *
37
+ * Storage:
38
+ * - `order_escalation_rules` + `order_escalations`
39
+ * (migration `0144_order_escalation.sql`).
40
+ *
41
+ * @primitive orderEscalation
42
+ * @related fraudScreen, orderNotes, notifications
43
+ */
44
+
45
+ // ---- constants ----------------------------------------------------------
46
+
47
+ var MAX_SLUG_LEN = 80;
48
+ var MAX_TAG_COUNT = 16;
49
+ var MAX_TAG_LEN = 64;
50
+ var MAX_NOTES_LEN = 4000;
51
+ var MAX_RESOLUTION_LEN = 1000;
52
+ var MAX_FALSE_POSITIVE_LEN = 1000;
53
+ var MAX_TEMPLATE_LEN = 128;
54
+ var MAX_COUNTRY_COUNT = 250; // every UN country + change
55
+ var MAX_PAYMENT_KIND_COUNT = 32;
56
+ var MAX_PAYMENT_KIND_LEN = 32;
57
+ var MAX_LIST_LIMIT = 200;
58
+
59
+ var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
60
+ var COUNTRY_RE = /^[A-Z]{2}$/;
61
+ var PAYMENT_KIND_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
62
+ var TAG_RE = /^[A-Za-z0-9][A-Za-z0-9._:\-]{0,63}$/;
63
+ var TEMPLATE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
64
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
65
+
66
+ var SEVERITIES = Object.freeze(["info", "warning", "critical"]);
67
+ var SEVERITY_RANK = Object.freeze({ info: 0, warning: 1, critical: 2 });
68
+ var STATUSES = Object.freeze(["open", "resolved", "false_positive"]);
69
+
70
+ var ALLOWED_CONDITION_KEYS = Object.freeze([
71
+ "total_minor_min",
72
+ "country_in",
73
+ "prior_orders_max",
74
+ "fraud_score_min",
75
+ "refund_pending",
76
+ "payment_method_kind_in",
77
+ ]);
78
+
79
+ var ALLOWED_PATCH_COLUMNS = Object.freeze([
80
+ "conditions",
81
+ "severity",
82
+ "assigned_operator",
83
+ "auto_tags",
84
+ "notification_template",
85
+ ]);
86
+
87
+ var bShop;
88
+ function _b() {
89
+ if (!bShop) bShop = require("./index");
90
+ return bShop.framework;
91
+ }
92
+
93
+ // ---- monotonic clock ----------------------------------------------------
94
+ //
95
+ // Hot-path writes mint `created_at` / `updated_at` / `occurred_at` from a
96
+ // monotonically-strictly-increasing clock. Two writes inside the same
97
+ // millisecond bump the second by +1 ms so the (severity, occurred_at)
98
+ // keyset on `idx_order_escalations_open_severity` stays a strict total
99
+ // order — operator-console "newest first" never collapses two rows into
100
+ // the same tie that the SQL ORDER BY can't break.
101
+
102
+ var _lastTs = 0;
103
+ function _now() {
104
+ var t = Date.now();
105
+ if (t <= _lastTs) { t = _lastTs + 1; }
106
+ _lastTs = t;
107
+ return t;
108
+ }
109
+
110
+ // ---- validators ---------------------------------------------------------
111
+
112
+ function _slug(s) {
113
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
114
+ throw new TypeError(
115
+ "orderEscalation: slug must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " +
116
+ MAX_SLUG_LEN + " chars)"
117
+ );
118
+ }
119
+ return s;
120
+ }
121
+
122
+ function _severity(s) {
123
+ if (typeof s !== "string" || SEVERITIES.indexOf(s) === -1) {
124
+ throw new TypeError("orderEscalation: severity must be one of " + SEVERITIES.join(", "));
125
+ }
126
+ return s;
127
+ }
128
+
129
+ function _uuidOrNull(v, label) {
130
+ if (v == null) return null;
131
+ try { return _b().guardUuid.sanitize(v, { profile: "strict" }); }
132
+ catch (e) {
133
+ throw new TypeError("orderEscalation: " + label + " — " + (e && e.message || "invalid UUID"));
134
+ }
135
+ }
136
+
137
+ function _uuid(v, label) {
138
+ try { return _b().guardUuid.sanitize(v, { profile: "strict" }); }
139
+ catch (e) {
140
+ throw new TypeError("orderEscalation: " + label + " — " + (e && e.message || "invalid UUID"));
141
+ }
142
+ }
143
+
144
+ function _intMin(v, label, min) {
145
+ if (!Number.isInteger(v) || v < min) {
146
+ throw new TypeError(
147
+ "orderEscalation: " + label + " must be an integer >= " + min
148
+ );
149
+ }
150
+ return v;
151
+ }
152
+
153
+ function _intRange(v, label, min, max) {
154
+ if (!Number.isInteger(v) || v < min || v > max) {
155
+ throw new TypeError(
156
+ "orderEscalation: " + label + " must be an integer in [" + min + ", " + max + "]"
157
+ );
158
+ }
159
+ return v;
160
+ }
161
+
162
+ function _bool(v, label) {
163
+ if (typeof v !== "boolean") {
164
+ throw new TypeError("orderEscalation: " + label + " must be a boolean");
165
+ }
166
+ return v;
167
+ }
168
+
169
+ function _country(s) {
170
+ if (typeof s !== "string" || !COUNTRY_RE.test(s)) {
171
+ throw new TypeError(
172
+ "orderEscalation: country_in entries must be ISO-3166 alpha-2 (uppercase, 2 letters)"
173
+ );
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _countryIn(arr) {
179
+ if (!Array.isArray(arr)) {
180
+ throw new TypeError("orderEscalation: country_in must be an array of ISO-3166 alpha-2 strings");
181
+ }
182
+ if (!arr.length) {
183
+ throw new TypeError("orderEscalation: country_in must be non-empty when provided");
184
+ }
185
+ if (arr.length > MAX_COUNTRY_COUNT) {
186
+ throw new TypeError("orderEscalation: country_in length " + arr.length + " exceeds cap " + MAX_COUNTRY_COUNT);
187
+ }
188
+ var seen = Object.create(null);
189
+ var out = [];
190
+ for (var i = 0; i < arr.length; i += 1) {
191
+ var c = _country(arr[i]);
192
+ if (seen[c]) {
193
+ throw new TypeError("orderEscalation: country_in contains duplicate " + JSON.stringify(c));
194
+ }
195
+ seen[c] = true;
196
+ out.push(c);
197
+ }
198
+ return out;
199
+ }
200
+
201
+ function _paymentKind(s) {
202
+ if (typeof s !== "string" || !PAYMENT_KIND_RE.test(s)) {
203
+ throw new TypeError(
204
+ "orderEscalation: payment_method_kind_in entries must match /^[a-z0-9][a-z0-9_-]*$/ (<= " +
205
+ MAX_PAYMENT_KIND_LEN + " chars)"
206
+ );
207
+ }
208
+ return s;
209
+ }
210
+
211
+ function _paymentKindIn(arr) {
212
+ if (!Array.isArray(arr)) {
213
+ throw new TypeError(
214
+ "orderEscalation: payment_method_kind_in must be an array of payment-kind strings"
215
+ );
216
+ }
217
+ if (!arr.length) {
218
+ throw new TypeError("orderEscalation: payment_method_kind_in must be non-empty when provided");
219
+ }
220
+ if (arr.length > MAX_PAYMENT_KIND_COUNT) {
221
+ throw new TypeError(
222
+ "orderEscalation: payment_method_kind_in length " + arr.length +
223
+ " exceeds cap " + MAX_PAYMENT_KIND_COUNT
224
+ );
225
+ }
226
+ var seen = Object.create(null);
227
+ var out = [];
228
+ for (var i = 0; i < arr.length; i += 1) {
229
+ var k = _paymentKind(arr[i]);
230
+ if (seen[k]) {
231
+ throw new TypeError("orderEscalation: payment_method_kind_in contains duplicate " + JSON.stringify(k));
232
+ }
233
+ seen[k] = true;
234
+ out.push(k);
235
+ }
236
+ return out;
237
+ }
238
+
239
+ function _conditions(v) {
240
+ if (v == null) return {};
241
+ if (typeof v !== "object" || Array.isArray(v)) {
242
+ throw new TypeError(
243
+ "orderEscalation: conditions must be an object with optional keys " +
244
+ ALLOWED_CONDITION_KEYS.join(", ")
245
+ );
246
+ }
247
+ var keys = Object.keys(v);
248
+ var out = {};
249
+ for (var i = 0; i < keys.length; i += 1) {
250
+ var k = keys[i];
251
+ if (ALLOWED_CONDITION_KEYS.indexOf(k) === -1) {
252
+ throw new TypeError(
253
+ "orderEscalation: conditions unknown key " + JSON.stringify(k) +
254
+ " (allowed: " + ALLOWED_CONDITION_KEYS.join(", ") + ")"
255
+ );
256
+ }
257
+ if (k === "total_minor_min") {
258
+ out[k] = _intMin(v[k], "conditions.total_minor_min", 0);
259
+ } else if (k === "country_in") {
260
+ out[k] = _countryIn(v[k]);
261
+ } else if (k === "prior_orders_max") {
262
+ out[k] = _intMin(v[k], "conditions.prior_orders_max", 0);
263
+ } else if (k === "fraud_score_min") {
264
+ out[k] = _intRange(v[k], "conditions.fraud_score_min", 0, 100);
265
+ } else if (k === "refund_pending") {
266
+ out[k] = _bool(v[k], "conditions.refund_pending");
267
+ } else /* payment_method_kind_in */ {
268
+ out[k] = _paymentKindIn(v[k]);
269
+ }
270
+ }
271
+ if (!Object.keys(out).length) {
272
+ throw new TypeError(
273
+ "orderEscalation: conditions must include at least one of " +
274
+ ALLOWED_CONDITION_KEYS.join(", ")
275
+ );
276
+ }
277
+ return out;
278
+ }
279
+
280
+ function _tags(arr) {
281
+ if (arr == null) return [];
282
+ if (!Array.isArray(arr)) {
283
+ throw new TypeError("orderEscalation: auto_tags must be an array of tag strings");
284
+ }
285
+ if (arr.length > MAX_TAG_COUNT) {
286
+ throw new TypeError("orderEscalation: auto_tags length " + arr.length + " exceeds cap " + MAX_TAG_COUNT);
287
+ }
288
+ var seen = Object.create(null);
289
+ var out = [];
290
+ for (var i = 0; i < arr.length; i += 1) {
291
+ var t = arr[i];
292
+ if (typeof t !== "string" || !TAG_RE.test(t)) {
293
+ throw new TypeError(
294
+ "orderEscalation: auto_tags[" + i + "] must match /^[A-Za-z0-9][A-Za-z0-9._:\\-]*$/ (<= " +
295
+ MAX_TAG_LEN + " chars)"
296
+ );
297
+ }
298
+ if (seen[t]) {
299
+ throw new TypeError("orderEscalation: auto_tags contains duplicate " + JSON.stringify(t));
300
+ }
301
+ seen[t] = true;
302
+ out.push(t);
303
+ }
304
+ return out;
305
+ }
306
+
307
+ function _template(s) {
308
+ if (s == null) return null;
309
+ if (typeof s !== "string" || !TEMPLATE_RE.test(s)) {
310
+ throw new TypeError(
311
+ "orderEscalation: notification_template must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " +
312
+ MAX_TEMPLATE_LEN + " chars)"
313
+ );
314
+ }
315
+ return s;
316
+ }
317
+
318
+ function _freeText(v, label, max) {
319
+ if (v == null) return null;
320
+ if (typeof v !== "string") {
321
+ throw new TypeError("orderEscalation: " + label + " must be a string when provided");
322
+ }
323
+ if (!v.length) {
324
+ throw new TypeError("orderEscalation: " + label + " must be non-empty when provided");
325
+ }
326
+ if (v.length > max) {
327
+ throw new TypeError("orderEscalation: " + label + " must be <= " + max + " characters");
328
+ }
329
+ if (CONTROL_BYTE_RE.test(v)) {
330
+ throw new TypeError("orderEscalation: " + label + " must not contain control bytes");
331
+ }
332
+ return v;
333
+ }
334
+
335
+ function _severityMin(s) {
336
+ if (s == null) return null;
337
+ _severity(s); // type / enum gate
338
+ return SEVERITY_RANK[s];
339
+ }
340
+
341
+ function _limit(n) {
342
+ if (n == null) return 50;
343
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
344
+ throw new TypeError("orderEscalation: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
345
+ }
346
+ return n;
347
+ }
348
+
349
+ // ---- order-snapshot readers --------------------------------------------
350
+
351
+ function _readSnapshot(snap) {
352
+ if (!snap || typeof snap !== "object" || Array.isArray(snap)) {
353
+ throw new TypeError("orderEscalation.evaluateOrder: order_snapshot must be an object");
354
+ }
355
+ var out = {};
356
+ if (snap.total_minor != null) {
357
+ if (!Number.isInteger(snap.total_minor) || snap.total_minor < 0) {
358
+ throw new TypeError("orderEscalation.evaluateOrder: order_snapshot.total_minor must be a non-negative integer");
359
+ }
360
+ out.total_minor = snap.total_minor;
361
+ }
362
+ if (snap.country != null) {
363
+ if (typeof snap.country !== "string" || !COUNTRY_RE.test(snap.country)) {
364
+ throw new TypeError("orderEscalation.evaluateOrder: order_snapshot.country must be ISO-3166 alpha-2");
365
+ }
366
+ out.country = snap.country;
367
+ }
368
+ if (snap.prior_orders != null) {
369
+ if (!Number.isInteger(snap.prior_orders) || snap.prior_orders < 0) {
370
+ throw new TypeError("orderEscalation.evaluateOrder: order_snapshot.prior_orders must be a non-negative integer");
371
+ }
372
+ out.prior_orders = snap.prior_orders;
373
+ }
374
+ if (snap.fraud_score != null) {
375
+ if (!Number.isInteger(snap.fraud_score) || snap.fraud_score < 0 || snap.fraud_score > 100) {
376
+ throw new TypeError("orderEscalation.evaluateOrder: order_snapshot.fraud_score must be an integer in [0, 100]");
377
+ }
378
+ out.fraud_score = snap.fraud_score;
379
+ }
380
+ if (snap.refund_pending != null) {
381
+ if (typeof snap.refund_pending !== "boolean") {
382
+ throw new TypeError("orderEscalation.evaluateOrder: order_snapshot.refund_pending must be a boolean");
383
+ }
384
+ out.refund_pending = snap.refund_pending;
385
+ }
386
+ if (snap.payment_method_kind != null) {
387
+ if (typeof snap.payment_method_kind !== "string" || !PAYMENT_KIND_RE.test(snap.payment_method_kind)) {
388
+ throw new TypeError(
389
+ "orderEscalation.evaluateOrder: order_snapshot.payment_method_kind must match " +
390
+ "/^[a-z0-9][a-z0-9_-]*$/"
391
+ );
392
+ }
393
+ out.payment_method_kind = snap.payment_method_kind;
394
+ }
395
+ return out;
396
+ }
397
+
398
+ // ---- condition matching -------------------------------------------------
399
+ //
400
+ // A rule fires when every condition in its bag matches the snapshot.
401
+ // A condition that references a dimension the snapshot omits never
402
+ // matches — the orchestrator is expected to populate every dimension
403
+ // the rule set actually addresses.
404
+
405
+ function _matchesRule(conditions, snap) {
406
+ if (Object.prototype.hasOwnProperty.call(conditions, "total_minor_min")) {
407
+ if (snap.total_minor == null) return false;
408
+ if (snap.total_minor < conditions.total_minor_min) return false;
409
+ }
410
+ if (Object.prototype.hasOwnProperty.call(conditions, "country_in")) {
411
+ if (snap.country == null) return false;
412
+ if (conditions.country_in.indexOf(snap.country) === -1) return false;
413
+ }
414
+ if (Object.prototype.hasOwnProperty.call(conditions, "prior_orders_max")) {
415
+ if (snap.prior_orders == null) return false;
416
+ if (snap.prior_orders > conditions.prior_orders_max) return false;
417
+ }
418
+ if (Object.prototype.hasOwnProperty.call(conditions, "fraud_score_min")) {
419
+ if (snap.fraud_score == null) return false;
420
+ if (snap.fraud_score < conditions.fraud_score_min) return false;
421
+ }
422
+ if (Object.prototype.hasOwnProperty.call(conditions, "refund_pending")) {
423
+ if (snap.refund_pending == null) return false;
424
+ if (snap.refund_pending !== conditions.refund_pending) return false;
425
+ }
426
+ if (Object.prototype.hasOwnProperty.call(conditions, "payment_method_kind_in")) {
427
+ if (snap.payment_method_kind == null) return false;
428
+ if (conditions.payment_method_kind_in.indexOf(snap.payment_method_kind) === -1) return false;
429
+ }
430
+ return true;
431
+ }
432
+
433
+ // ---- row hydration ------------------------------------------------------
434
+
435
+ function _safeParseObject(s, fallback) {
436
+ if (s == null) return fallback;
437
+ try {
438
+ var parsed = JSON.parse(s);
439
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
440
+ return fallback;
441
+ } catch (_e) { return fallback; }
442
+ }
443
+
444
+ function _safeParseArray(s) {
445
+ if (s == null) return [];
446
+ try {
447
+ var parsed = JSON.parse(s);
448
+ if (Array.isArray(parsed)) return parsed;
449
+ return [];
450
+ } catch (_e) { return []; }
451
+ }
452
+
453
+ function _hydrateRule(r) {
454
+ if (!r) return null;
455
+ return {
456
+ slug: r.slug,
457
+ conditions: _safeParseObject(r.conditions_json, {}),
458
+ severity: r.severity,
459
+ assigned_operator: r.assigned_operator == null ? null : r.assigned_operator,
460
+ auto_tags: _safeParseArray(r.auto_tags_json),
461
+ notification_template: r.notification_template == null ? null : r.notification_template,
462
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
463
+ created_at: Number(r.created_at),
464
+ updated_at: Number(r.updated_at),
465
+ };
466
+ }
467
+
468
+ function _hydrateEscalation(r) {
469
+ if (!r) return null;
470
+ return {
471
+ id: r.id,
472
+ order_id: r.order_id,
473
+ rule_slug: r.rule_slug,
474
+ severity: r.severity,
475
+ assigned_operator: r.assigned_operator == null ? null : r.assigned_operator,
476
+ notes: r.notes == null ? null : r.notes,
477
+ status: r.status,
478
+ resolution: r.resolution == null ? null : r.resolution,
479
+ resolved_at: r.resolved_at == null ? null : Number(r.resolved_at),
480
+ occurred_at: Number(r.occurred_at),
481
+ };
482
+ }
483
+
484
+ // ---- factory ------------------------------------------------------------
485
+
486
+ function create(opts) {
487
+ opts = opts || {};
488
+ var query = opts.query;
489
+ if (!query) {
490
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
491
+ }
492
+ // Optional side-effect hooks. Both are stubs in the test path; the
493
+ // operator wires the real `notifications` + `orderTags` primitives
494
+ // from `bShop` in production. Missing hooks are tolerated — the
495
+ // primitive falls back to a no-op so `recordEscalation` still
496
+ // returns a usable row even when only the FSM state is needed.
497
+ var notifications = opts.notifications || null;
498
+ var orderTags = opts.orderTags || null;
499
+
500
+ // ---- defineRule -----------------------------------------------------
501
+
502
+ async function defineRule(input) {
503
+ if (!input || typeof input !== "object") {
504
+ throw new TypeError("orderEscalation.defineRule: input object required");
505
+ }
506
+ var slug = _slug(input.slug);
507
+ var conditions = _conditions(input.conditions);
508
+ var severity = _severity(input.severity);
509
+ var assignedOperator = _uuidOrNull(input.assigned_operator, "assigned_operator");
510
+ var autoTags = _tags(input.auto_tags);
511
+ var template = _template(input.notification_template);
512
+
513
+ var existing = (await query(
514
+ "SELECT slug FROM order_escalation_rules WHERE slug = ?1 LIMIT 1",
515
+ [slug],
516
+ )).rows[0];
517
+ if (existing) {
518
+ throw new TypeError(
519
+ "orderEscalation.defineRule: slug " + JSON.stringify(slug) +
520
+ " already exists - use updateRule"
521
+ );
522
+ }
523
+
524
+ var ts = _now();
525
+ await query(
526
+ "INSERT INTO order_escalation_rules " +
527
+ "(slug, conditions_json, severity, assigned_operator, auto_tags_json, " +
528
+ "notification_template, archived_at, created_at, updated_at) " +
529
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
530
+ [
531
+ slug,
532
+ JSON.stringify(conditions),
533
+ severity,
534
+ assignedOperator,
535
+ JSON.stringify(autoTags),
536
+ template,
537
+ ts,
538
+ ],
539
+ );
540
+ return await _getRule(slug);
541
+ }
542
+
543
+ async function _getRule(slug) {
544
+ var row = (await query(
545
+ "SELECT * FROM order_escalation_rules WHERE slug = ?1 LIMIT 1",
546
+ [slug],
547
+ )).rows[0];
548
+ return _hydrateRule(row);
549
+ }
550
+
551
+ // ---- listRules / updateRule / archiveRule --------------------------
552
+
553
+ async function listRules(listOpts) {
554
+ listOpts = listOpts || {};
555
+ var includeArchived = false;
556
+ if (listOpts.include_archived != null) {
557
+ includeArchived = _bool(listOpts.include_archived, "include_archived");
558
+ }
559
+ var limit = _limit(listOpts.limit);
560
+ var sql, params;
561
+ if (includeArchived) {
562
+ sql = "SELECT * FROM order_escalation_rules " +
563
+ "ORDER BY archived_at IS NULL DESC, severity DESC, created_at ASC, slug ASC LIMIT ?1";
564
+ params = [limit];
565
+ } else {
566
+ sql = "SELECT * FROM order_escalation_rules WHERE archived_at IS NULL " +
567
+ "ORDER BY severity DESC, created_at ASC, slug ASC LIMIT ?1";
568
+ params = [limit];
569
+ }
570
+ var rows = (await query(sql, params)).rows;
571
+ var out = [];
572
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRule(rows[i]));
573
+ return out;
574
+ }
575
+
576
+ async function updateRule(slug, patch) {
577
+ _slug(slug);
578
+ if (!patch || typeof patch !== "object") {
579
+ throw new TypeError("orderEscalation.updateRule: patch object required");
580
+ }
581
+ var keys = Object.keys(patch);
582
+ if (!keys.length) {
583
+ throw new TypeError("orderEscalation.updateRule: patch must include at least one column");
584
+ }
585
+ var current = await _getRule(slug);
586
+ if (!current) {
587
+ throw new TypeError("orderEscalation.updateRule: slug " + JSON.stringify(slug) + " not found");
588
+ }
589
+
590
+ var sets = [];
591
+ var params = [];
592
+ var idx = 1;
593
+
594
+ for (var i = 0; i < keys.length; i += 1) {
595
+ var col = keys[i];
596
+ if (ALLOWED_PATCH_COLUMNS.indexOf(col) === -1) {
597
+ throw new TypeError("orderEscalation.updateRule: unsupported column " + JSON.stringify(col));
598
+ }
599
+ if (col === "conditions") {
600
+ sets.push("conditions_json = ?" + idx);
601
+ params.push(JSON.stringify(_conditions(patch[col])));
602
+ } else if (col === "severity") {
603
+ sets.push("severity = ?" + idx);
604
+ params.push(_severity(patch[col]));
605
+ } else if (col === "assigned_operator") {
606
+ sets.push("assigned_operator = ?" + idx);
607
+ params.push(_uuidOrNull(patch[col], "assigned_operator"));
608
+ } else if (col === "auto_tags") {
609
+ sets.push("auto_tags_json = ?" + idx);
610
+ params.push(JSON.stringify(_tags(patch[col])));
611
+ } else /* notification_template */ {
612
+ sets.push("notification_template = ?" + idx);
613
+ params.push(_template(patch[col]));
614
+ }
615
+ idx += 1;
616
+ }
617
+
618
+ sets.push("updated_at = ?" + idx);
619
+ params.push(_now());
620
+ idx += 1;
621
+ params.push(slug);
622
+
623
+ var r = await query(
624
+ "UPDATE order_escalation_rules SET " + sets.join(", ") + " WHERE slug = ?" + idx,
625
+ params,
626
+ );
627
+ if (Number(r.rowCount || 0) === 0) {
628
+ throw new TypeError("orderEscalation.updateRule: slug " + JSON.stringify(slug) + " not found");
629
+ }
630
+ return await _getRule(slug);
631
+ }
632
+
633
+ async function archiveRule(slug) {
634
+ _slug(slug);
635
+ var ts = _now();
636
+ var r = await query(
637
+ "UPDATE order_escalation_rules SET archived_at = ?1, updated_at = ?1 " +
638
+ "WHERE slug = ?2 AND archived_at IS NULL",
639
+ [ts, slug],
640
+ );
641
+ if (Number(r.rowCount || 0) === 0) {
642
+ // Either missing or already archived. Re-fetch to disambiguate.
643
+ var existing = await _getRule(slug);
644
+ if (!existing) {
645
+ throw new TypeError("orderEscalation.archiveRule: slug " + JSON.stringify(slug) + " not found");
646
+ }
647
+ return existing;
648
+ }
649
+ return await _getRule(slug);
650
+ }
651
+
652
+ // ---- evaluateOrder -------------------------------------------------
653
+
654
+ async function evaluateOrder(input) {
655
+ if (!input || typeof input !== "object") {
656
+ throw new TypeError("orderEscalation.evaluateOrder: input object required");
657
+ }
658
+ var orderId = _uuid(input.order_id, "order_id");
659
+ var snap = _readSnapshot(input.order_snapshot);
660
+
661
+ var rows = (await query(
662
+ "SELECT * FROM order_escalation_rules WHERE archived_at IS NULL " +
663
+ "ORDER BY severity DESC, created_at ASC, slug ASC",
664
+ [],
665
+ )).rows;
666
+
667
+ var matchedRules = [];
668
+ var tagsApplied = [];
669
+ var seenTags = Object.create(null);
670
+ for (var i = 0; i < rows.length; i += 1) {
671
+ var rule = _hydrateRule(rows[i]);
672
+ if (!_matchesRule(rule.conditions, snap)) continue;
673
+ matchedRules.push(rule);
674
+ for (var j = 0; j < rule.auto_tags.length; j += 1) {
675
+ if (!seenTags[rule.auto_tags[j]]) {
676
+ seenTags[rule.auto_tags[j]] = true;
677
+ tagsApplied.push(rule.auto_tags[j]);
678
+ }
679
+ }
680
+ }
681
+ return { order_id: orderId, matched_rules: matchedRules, tags_applied: tagsApplied };
682
+ }
683
+
684
+ // ---- recordEscalation ----------------------------------------------
685
+
686
+ async function recordEscalation(input) {
687
+ if (!input || typeof input !== "object") {
688
+ throw new TypeError("orderEscalation.recordEscalation: input object required");
689
+ }
690
+ var orderId = _uuid(input.order_id, "order_id");
691
+ var ruleSlug = _slug(input.rule_slug);
692
+ var severity = _severity(input.severity);
693
+ var assignedOperator = _uuidOrNull(input.assigned_operator, "assigned_operator");
694
+ var notes = _freeText(input.notes, "notes", MAX_NOTES_LEN);
695
+
696
+ var rule = await _getRule(ruleSlug);
697
+ if (!rule) {
698
+ throw new TypeError(
699
+ "orderEscalation.recordEscalation: rule_slug " + JSON.stringify(ruleSlug) + " not found"
700
+ );
701
+ }
702
+
703
+ var id = _b().uuid.v7();
704
+ var ts = _now();
705
+ await query(
706
+ "INSERT INTO order_escalations " +
707
+ "(id, order_id, rule_slug, severity, assigned_operator, notes, status, " +
708
+ "resolution, resolved_at, occurred_at) " +
709
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'open', NULL, NULL, ?7)",
710
+ [id, orderId, ruleSlug, severity, assignedOperator, notes, ts],
711
+ );
712
+
713
+ // Apply auto-tags via the operator-supplied hook. Each call is
714
+ // wrapped so a single tag failure doesn't abort the escalation —
715
+ // the row is the source of truth for "we noticed this order",
716
+ // tag application is a best-effort downstream side-effect.
717
+ var taggedApplied = [];
718
+ var taggedFailed = [];
719
+ if (orderTags && typeof orderTags.applyTag === "function") {
720
+ for (var t = 0; t < rule.auto_tags.length; t += 1) {
721
+ var tag = rule.auto_tags[t];
722
+ try {
723
+ await orderTags.applyTag(orderId, tag);
724
+ taggedApplied.push(tag);
725
+ } catch (e) {
726
+ taggedFailed.push({ tag: tag, error: e && e.message || "applyTag failed" });
727
+ }
728
+ }
729
+ }
730
+
731
+ // Dispatch notification — only when the rule named a template AND
732
+ // an operator is assigned (the recipient surface this primitive
733
+ // knows about). A template without an assignee is a no-op; the
734
+ // operator dashboard surfaces the unassigned escalation directly
735
+ // via `openEscalations`.
736
+ var notified = false;
737
+ if (notifications && typeof notifications.enqueue === "function"
738
+ && rule.notification_template && assignedOperator) {
739
+ try {
740
+ await notifications.enqueue({
741
+ recipient_id: assignedOperator,
742
+ event_type: "order_escalation",
743
+ channel: "in-app",
744
+ template: rule.notification_template,
745
+ payload: {
746
+ escalation_id: id,
747
+ order_id: orderId,
748
+ rule_slug: ruleSlug,
749
+ severity: severity,
750
+ },
751
+ });
752
+ notified = true;
753
+ } catch (_eN) {
754
+ // Surface the dispatch failure in the return shape; the
755
+ // escalation row itself stays committed.
756
+ notified = false;
757
+ }
758
+ }
759
+
760
+ var escalation = await _getEscalation(id);
761
+ return {
762
+ escalation: escalation,
763
+ tags_applied: taggedApplied,
764
+ tags_failed: taggedFailed,
765
+ notified: notified,
766
+ };
767
+ }
768
+
769
+ async function _getEscalation(id) {
770
+ var row = (await query(
771
+ "SELECT * FROM order_escalations WHERE id = ?1 LIMIT 1",
772
+ [id],
773
+ )).rows[0];
774
+ return _hydrateEscalation(row);
775
+ }
776
+
777
+ // ---- FSM transitions ----------------------------------------------
778
+
779
+ async function resolveEscalation(input) {
780
+ if (!input || typeof input !== "object") {
781
+ throw new TypeError("orderEscalation.resolveEscalation: input object required");
782
+ }
783
+ var id = _uuid(input.escalation_id, "escalation_id");
784
+ var resolution = _freeText(input.resolution, "resolution", MAX_RESOLUTION_LEN);
785
+ if (resolution == null) {
786
+ throw new TypeError("orderEscalation.resolveEscalation: resolution required");
787
+ }
788
+ var current = await _getEscalation(id);
789
+ if (!current) {
790
+ throw new TypeError("orderEscalation.resolveEscalation: escalation " + id + " not found");
791
+ }
792
+ if (current.status !== "open") {
793
+ throw new TypeError(
794
+ "orderEscalation.resolveEscalation: escalation " + id +
795
+ " not in open state (current: " + current.status + ")"
796
+ );
797
+ }
798
+ var ts = _now();
799
+ await query(
800
+ "UPDATE order_escalations SET status = 'resolved', resolution = ?1, resolved_at = ?2 " +
801
+ "WHERE id = ?3 AND status = 'open'",
802
+ [resolution, ts, id],
803
+ );
804
+ return await _getEscalation(id);
805
+ }
806
+
807
+ async function markAsFalsePositive(input) {
808
+ if (!input || typeof input !== "object") {
809
+ throw new TypeError("orderEscalation.markAsFalsePositive: input object required");
810
+ }
811
+ var id = _uuid(input.escalation_id, "escalation_id");
812
+ var reason = _freeText(input.reason, "reason", MAX_FALSE_POSITIVE_LEN);
813
+ if (reason == null) {
814
+ throw new TypeError("orderEscalation.markAsFalsePositive: reason required");
815
+ }
816
+ var current = await _getEscalation(id);
817
+ if (!current) {
818
+ throw new TypeError("orderEscalation.markAsFalsePositive: escalation " + id + " not found");
819
+ }
820
+ if (current.status !== "open") {
821
+ throw new TypeError(
822
+ "orderEscalation.markAsFalsePositive: escalation " + id +
823
+ " not in open state (current: " + current.status + ")"
824
+ );
825
+ }
826
+ var ts = _now();
827
+ await query(
828
+ "UPDATE order_escalations SET status = 'false_positive', resolution = ?1, resolved_at = ?2 " +
829
+ "WHERE id = ?3 AND status = 'open'",
830
+ [reason, ts, id],
831
+ );
832
+ return await _getEscalation(id);
833
+ }
834
+
835
+ // ---- read paths ---------------------------------------------------
836
+
837
+ async function openEscalations(listOpts) {
838
+ listOpts = listOpts || {};
839
+ var operatorId = listOpts.operator_id == null ? null : _uuid(listOpts.operator_id, "operator_id");
840
+ var severityMin = _severityMin(listOpts.severity_min);
841
+
842
+ var where = ["status = 'open'"];
843
+ var params = [];
844
+ var idx = 1;
845
+ if (operatorId != null) {
846
+ where.push("assigned_operator = ?" + idx);
847
+ params.push(operatorId);
848
+ idx += 1;
849
+ }
850
+ // severity_min is enforced in JS rather than SQL so the
851
+ // operator-readable enum stays the source of truth (SQLite can't
852
+ // sort the textual severity column by the desired rank without an
853
+ // index expression the migration would have to maintain).
854
+ var rows = (await query(
855
+ "SELECT * FROM order_escalations WHERE " + where.join(" AND ") +
856
+ " ORDER BY occurred_at DESC",
857
+ params,
858
+ )).rows;
859
+
860
+ var out = [];
861
+ for (var i = 0; i < rows.length; i += 1) {
862
+ var hyd = _hydrateEscalation(rows[i]);
863
+ if (severityMin != null && SEVERITY_RANK[hyd.severity] < severityMin) continue;
864
+ out.push(hyd);
865
+ }
866
+ return out;
867
+ }
868
+
869
+ async function escalationsForOrder(orderId) {
870
+ orderId = _uuid(orderId, "order_id");
871
+ var rows = (await query(
872
+ "SELECT * FROM order_escalations WHERE order_id = ?1 ORDER BY occurred_at DESC",
873
+ [orderId],
874
+ )).rows;
875
+ var out = [];
876
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateEscalation(rows[i]));
877
+ return out;
878
+ }
879
+
880
+ async function metricsForRule(input) {
881
+ if (!input || typeof input !== "object") {
882
+ throw new TypeError("orderEscalation.metricsForRule: input object required");
883
+ }
884
+ var slug = _slug(input.slug);
885
+ if (!Number.isInteger(input.from) || input.from < 0) {
886
+ throw new TypeError("orderEscalation.metricsForRule: from must be a non-negative integer epoch-ms");
887
+ }
888
+ if (!Number.isInteger(input.to) || input.to < input.from) {
889
+ throw new TypeError("orderEscalation.metricsForRule: to must be a non-negative integer epoch-ms >= from");
890
+ }
891
+ var rows = (await query(
892
+ "SELECT status, COUNT(*) AS n FROM order_escalations " +
893
+ "WHERE rule_slug = ?1 AND occurred_at >= ?2 AND occurred_at <= ?3 " +
894
+ "GROUP BY status",
895
+ [slug, input.from, input.to],
896
+ )).rows;
897
+ var counts = { open: 0, resolved: 0, false_positive: 0 };
898
+ for (var i = 0; i < rows.length; i += 1) {
899
+ var k = rows[i].status;
900
+ if (counts[k] != null) counts[k] = Number(rows[i].n);
901
+ }
902
+ var fired = counts.open + counts.resolved + counts.false_positive;
903
+ var closed = counts.resolved + counts.false_positive;
904
+ var fpRateBps = closed === 0 ? 0 : Math.round((counts.false_positive / closed) * 10000);
905
+ return {
906
+ slug: slug,
907
+ from: input.from,
908
+ to: input.to,
909
+ fired: fired,
910
+ open: counts.open,
911
+ resolved: counts.resolved,
912
+ false_positive: counts.false_positive,
913
+ false_positive_bps: fpRateBps,
914
+ };
915
+ }
916
+
917
+ return {
918
+ SEVERITIES: SEVERITIES.slice(),
919
+ STATUSES: STATUSES.slice(),
920
+ ALLOWED_CONDITION_KEYS: ALLOWED_CONDITION_KEYS.slice(),
921
+ MAX_TAG_COUNT: MAX_TAG_COUNT,
922
+ MAX_TAG_LEN: MAX_TAG_LEN,
923
+ MAX_NOTES_LEN: MAX_NOTES_LEN,
924
+ MAX_RESOLUTION_LEN: MAX_RESOLUTION_LEN,
925
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
926
+
927
+ defineRule: defineRule,
928
+ listRules: listRules,
929
+ updateRule: updateRule,
930
+ archiveRule: archiveRule,
931
+ evaluateOrder: evaluateOrder,
932
+ recordEscalation: recordEscalation,
933
+ resolveEscalation: resolveEscalation,
934
+ markAsFalsePositive: markAsFalsePositive,
935
+ openEscalations: openEscalations,
936
+ escalationsForOrder: escalationsForOrder,
937
+ metricsForRule: metricsForRule,
938
+ };
939
+ }
940
+
941
+ module.exports = {
942
+ create: create,
943
+ SEVERITIES: SEVERITIES.slice(),
944
+ STATUSES: STATUSES.slice(),
945
+ ALLOWED_CONDITION_KEYS: ALLOWED_CONDITION_KEYS.slice(),
946
+ MAX_TAG_COUNT: MAX_TAG_COUNT,
947
+ MAX_TAG_LEN: MAX_TAG_LEN,
948
+ MAX_NOTES_LEN: MAX_NOTES_LEN,
949
+ MAX_RESOLUTION_LEN: MAX_RESOLUTION_LEN,
950
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
951
+ };