@blamejs/blamejs-shop 0.0.65 → 0.0.70

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 (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,944 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.orderTags
4
+ * @title Order tags — free-form operator-applied labels for workflow routing
5
+ *
6
+ * @intro
7
+ * Operators tag orders with short labels — "rush", "fraud-review",
8
+ * "wholesale", "demo", "vip-customer" — that drive admin-UI
9
+ * filtering and downstream automation (priority pick lists,
10
+ * manual fraud review queues, wholesale-only fulfillment lanes).
11
+ * Two application paths:
12
+ *
13
+ * - Manual: an admin clicks a tag pill on the order detail
14
+ * page; `applyTag` records who applied it, when, and an
15
+ * optional free-text reason.
16
+ * - Automatic: a rule named by `defineRule` watches order
17
+ * shape (total, country, line count, customer segment) and
18
+ * `applyRulesToOrder` evaluates every active rule against
19
+ * one order, applying the named tag when the rule matches.
20
+ * Caller invokes `applyRulesToOrder` at the point the
21
+ * order's shape stabilizes — typically the FSM's `paid`
22
+ * transition — not as a background sweep.
23
+ *
24
+ * Surface:
25
+ *
26
+ * - `defineTag({ slug, title, description?, color,
27
+ * default_color_for_status? })`
28
+ * Create a new tag definition. `slug` is the operator-
29
+ * facing handle (UNIQUE). `color` is a 6-hex CSS color
30
+ * (`#rrggbb`) the admin UI renders the pill in.
31
+ * `default_color_for_status`, when supplied, names one
32
+ * of the order FSM states — admin UIs may color rows in
33
+ * that state with this tag's palette even before the tag
34
+ * is applied (a "rush" tag with status='pending' lets
35
+ * pending orders preview the rush color).
36
+ *
37
+ * - `applyTag({ order_id, tag_slug, applied_by, reason? })`
38
+ * Apply a tag to one order. UNIQUE-refuses a second
39
+ * active assignment for the same (order, tag); call
40
+ * `removeTag` first if reapplication is intentional.
41
+ * Returns the assignment row.
42
+ *
43
+ * - `removeTag({ order_id, tag_slug, removed_by })`
44
+ * Soft-delete the active assignment. The historical row
45
+ * is preserved with `removed_at` + `removed_by` set;
46
+ * `historyForOrder` surfaces both apply and remove
47
+ * events.
48
+ *
49
+ * - `tagsForOrder(order_id)`
50
+ * Returns active (non-removed) tags for one order, with
51
+ * the tag definition hydrated alongside.
52
+ *
53
+ * - `ordersWithTag({ tag_slug, cursor?, limit? })`
54
+ * Returns order ids currently carrying the named tag,
55
+ * paginated via an HMAC-tagged cursor over
56
+ * (order_id ASC).
57
+ *
58
+ * - `popularTags({ limit })`
59
+ * Returns the most-used active tags ranked by current
60
+ * active-assignment count.
61
+ *
62
+ * - `defineRule({ slug, conditions, tag_slug })`
63
+ * Create an auto-application rule. Supported condition
64
+ * keys: `total_minor_min`, `total_minor_max`,
65
+ * `country_in`, `line_count_min`,
66
+ * `customer_segment_in`. Unknown keys are refused at
67
+ * define time so a typo doesn't silently produce a rule
68
+ * that matches everything (or nothing). `tag_slug` must
69
+ * reference a defined tag.
70
+ *
71
+ * - `applyRulesToOrder(order_id)`
72
+ * Evaluate every active rule against the order's current
73
+ * shape; returns `{ applied: [slug, ...], skipped:
74
+ * [{ rule_slug, reason }, ...] }`. Rules that match a
75
+ * tag already actively assigned to the order are
76
+ * no-ops (idempotent).
77
+ *
78
+ * - `listTags({ include_archived? })`
79
+ * Enumerate every defined tag.
80
+ *
81
+ * - `archiveTag(slug)` / `updateTag(slug, patch)`
82
+ * Soft-delete the definition (active assignments stay
83
+ * readable for audit) / mutate title / description /
84
+ * color / default_color_for_status.
85
+ *
86
+ * - `historyForOrder(order_id)`
87
+ * Append-only timeline of every apply + remove event for
88
+ * the order, oldest first.
89
+ *
90
+ * Composition:
91
+ *
92
+ * - b.guardUuid — every order_id / customer_id is shape-
93
+ * checked at the entry point.
94
+ * - b.uuid.v7 — assignment row ids (lexicographically
95
+ * sortable for diagnostic logs).
96
+ * - b.pagination — HMAC-tagged cursor on (order_id ASC)
97
+ * for `ordersWithTag` so an operator
98
+ * can't hand-craft a cursor to skip past
99
+ * a hidden row or replay across
100
+ * deployments.
101
+ *
102
+ * Storage:
103
+ *
104
+ * - `order_tags_defined` + `order_tag_assignments` +
105
+ * `order_tag_rules` (migration `0137_order_tags.sql`).
106
+ *
107
+ * Monotonic clock:
108
+ *
109
+ * `applied_at` is strictly monotonic per (order_id, tag_slug)
110
+ * across the full history — a re-apply after `removeTag`
111
+ * lands at `max(prior_applied_at, now) + 1` when the clock
112
+ * would otherwise tie or regress. The result is a totally-
113
+ * ordered timeline regardless of clock skew or rapid-fire
114
+ * operator clicks within the same millisecond.
115
+ *
116
+ * @primitive orderTags
117
+ * @related b.guardUuid, b.pagination, b.uuid, customerSegments
118
+ */
119
+
120
+ var bShop;
121
+ function _b() {
122
+ if (!bShop) bShop = require("./index");
123
+ return bShop.framework;
124
+ }
125
+
126
+ // ---- constants ----------------------------------------------------------
127
+
128
+ var DEFAULT_LIMIT = 100;
129
+ var MAX_LIMIT = 1000;
130
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
131
+ var COLOR_RE = /^#[0-9a-f]{6}$/;
132
+ var COUNTRY_RE = /^[A-Z]{2}$/;
133
+ var REASON_MAX = 500;
134
+ var BY_MAX = 200; // applied_by / removed_by — operator id or display name
135
+ var MAX_TITLE_LEN = 200;
136
+ var MAX_DESC_LEN = 1000;
137
+ var MAX_LIST_LEN = 64; // country_in / customer_segment_in caps
138
+ var ORDER_KEY = ["order_id:asc"];
139
+
140
+ var ALLOWED_STATUSES = Object.freeze([
141
+ "pending", "paid", "fulfilling", "shipped", "delivered", "refunded", "cancelled",
142
+ ]);
143
+
144
+ var KNOWN_CONDITION_KEYS = Object.freeze([
145
+ "total_minor_min",
146
+ "total_minor_max",
147
+ "country_in",
148
+ "line_count_min",
149
+ "customer_segment_in",
150
+ ]);
151
+
152
+ // ---- validators ---------------------------------------------------------
153
+
154
+ function _uuid(s, label) {
155
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
156
+ catch (e) { throw new TypeError("orderTags: " + label + " — " + (e && e.message || "invalid UUID")); }
157
+ }
158
+
159
+ function _slug(s, label) {
160
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
161
+ throw new TypeError("orderTags: " + (label || "slug") + " must match /^[a-z0-9][a-z0-9_-]*[a-z0-9]?$/ (<= 64 chars)");
162
+ }
163
+ return s;
164
+ }
165
+
166
+ function _title(s) {
167
+ if (typeof s !== "string" || !s.length) {
168
+ throw new TypeError("orderTags: title must be a non-empty string");
169
+ }
170
+ if (s.length > MAX_TITLE_LEN) {
171
+ throw new TypeError("orderTags: title must be <= " + MAX_TITLE_LEN + " chars");
172
+ }
173
+ if (/[\x00-\x1f\x7f]/.test(s)) {
174
+ throw new TypeError("orderTags: title must not contain control bytes");
175
+ }
176
+ return s;
177
+ }
178
+
179
+ function _description(s) {
180
+ if (s == null || s === "") return "";
181
+ if (typeof s !== "string") {
182
+ throw new TypeError("orderTags: description must be a string");
183
+ }
184
+ if (s.length > MAX_DESC_LEN) {
185
+ throw new TypeError("orderTags: description must be <= " + MAX_DESC_LEN + " chars");
186
+ }
187
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
188
+ throw new TypeError("orderTags: description must not contain control bytes");
189
+ }
190
+ return s;
191
+ }
192
+
193
+ function _color(s) {
194
+ if (typeof s !== "string" || !COLOR_RE.test(s)) {
195
+ throw new TypeError("orderTags: color must match /^#[0-9a-f]{6}$/ (lowercase hex)");
196
+ }
197
+ return s;
198
+ }
199
+
200
+ function _statusOrNull(s) {
201
+ if (s == null) return null;
202
+ if (typeof s !== "string" || ALLOWED_STATUSES.indexOf(s) === -1) {
203
+ throw new TypeError("orderTags: default_color_for_status must be null or one of " + ALLOWED_STATUSES.join(", "));
204
+ }
205
+ return s;
206
+ }
207
+
208
+ function _reason(s) {
209
+ if (s == null || s === "") return "";
210
+ if (typeof s !== "string") {
211
+ throw new TypeError("orderTags: reason must be a string");
212
+ }
213
+ if (s.length > REASON_MAX) {
214
+ throw new TypeError("orderTags: reason must be <= " + REASON_MAX + " chars");
215
+ }
216
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
217
+ throw new TypeError("orderTags: reason must not contain control bytes");
218
+ }
219
+ return s;
220
+ }
221
+
222
+ function _by(s, label) {
223
+ if (typeof s !== "string" || !s.length) {
224
+ throw new TypeError("orderTags: " + label + " must be a non-empty string");
225
+ }
226
+ if (s.length > BY_MAX) {
227
+ throw new TypeError("orderTags: " + label + " must be <= " + BY_MAX + " chars");
228
+ }
229
+ if (/[\x00-\x1f\x7f]/.test(s)) {
230
+ throw new TypeError("orderTags: " + label + " must not contain control bytes");
231
+ }
232
+ return s;
233
+ }
234
+
235
+ function _nonNegInt(n, label) {
236
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
237
+ throw new TypeError("orderTags: " + label + " must be a non-negative integer");
238
+ }
239
+ return n;
240
+ }
241
+
242
+ function _limit(n, label) {
243
+ if (n == null) return DEFAULT_LIMIT;
244
+ if (typeof n !== "number" || !Number.isInteger(n) || n < 1 || n > MAX_LIMIT) {
245
+ throw new TypeError("orderTags: " + label + " must be an integer in [1, " + MAX_LIMIT + "]");
246
+ }
247
+ return n;
248
+ }
249
+
250
+ function _stringList(arr, label, validator) {
251
+ if (!Array.isArray(arr) || arr.length === 0) {
252
+ throw new TypeError("orderTags: " + label + " must be a non-empty array");
253
+ }
254
+ if (arr.length > MAX_LIST_LEN) {
255
+ throw new TypeError("orderTags: " + label + " must contain <= " + MAX_LIST_LEN + " entries");
256
+ }
257
+ var seen = Object.create(null);
258
+ var out = [];
259
+ for (var i = 0; i < arr.length; i += 1) {
260
+ var v = arr[i];
261
+ if (typeof v !== "string") {
262
+ throw new TypeError("orderTags: " + label + "[" + i + "] must be a string");
263
+ }
264
+ if (validator) validator(v, label + "[" + i + "]");
265
+ if (seen[v]) {
266
+ throw new TypeError("orderTags: " + label + " has duplicate entry " + JSON.stringify(v));
267
+ }
268
+ seen[v] = true;
269
+ out.push(v);
270
+ }
271
+ return out;
272
+ }
273
+
274
+ function _countryEntry(v, label) {
275
+ if (!COUNTRY_RE.test(v)) {
276
+ throw new TypeError("orderTags: " + label + " must be a 2-letter ISO 3166-1 country code");
277
+ }
278
+ }
279
+
280
+ function _segmentEntry(v, label) {
281
+ if (!SLUG_RE.test(v)) {
282
+ throw new TypeError("orderTags: " + label + " must be a segment slug (matches /^[a-z0-9][a-z0-9_-]*$/)");
283
+ }
284
+ }
285
+
286
+ function _validateConditions(conditions) {
287
+ if (conditions == null || typeof conditions !== "object" || Array.isArray(conditions)) {
288
+ throw new TypeError("orderTags: conditions must be a plain object");
289
+ }
290
+ var keys = Object.keys(conditions);
291
+ if (keys.length === 0) {
292
+ throw new TypeError("orderTags: conditions must contain at least one predicate");
293
+ }
294
+ var out = {};
295
+ for (var i = 0; i < keys.length; i += 1) {
296
+ var k = keys[i];
297
+ if (KNOWN_CONDITION_KEYS.indexOf(k) === -1) {
298
+ throw new TypeError("orderTags: unknown condition key " + JSON.stringify(k) +
299
+ " (allowed: " + KNOWN_CONDITION_KEYS.join(", ") + ")");
300
+ }
301
+ var v = conditions[k];
302
+ switch (k) {
303
+ case "total_minor_min":
304
+ case "total_minor_max":
305
+ case "line_count_min":
306
+ _nonNegInt(v, "conditions." + k);
307
+ out[k] = v;
308
+ break;
309
+ case "country_in":
310
+ out[k] = _stringList(v, "conditions." + k, _countryEntry);
311
+ break;
312
+ case "customer_segment_in":
313
+ out[k] = _stringList(v, "conditions." + k, _segmentEntry);
314
+ break;
315
+ default:
316
+ // Unreachable — KNOWN_CONDITION_KEYS gate above catches anything
317
+ // else. Throwing here so a missing branch surfaces in development
318
+ // instead of silently dropping a predicate.
319
+ throw new TypeError("orderTags: condition key " + JSON.stringify(k) + " has no validator");
320
+ }
321
+ }
322
+ if (out.total_minor_min != null && out.total_minor_max != null
323
+ && out.total_minor_min > out.total_minor_max) {
324
+ throw new TypeError("orderTags: conditions.total_minor_min must be <= total_minor_max");
325
+ }
326
+ return out;
327
+ }
328
+
329
+ function _now() { return Date.now(); }
330
+
331
+ // ---- factory ------------------------------------------------------------
332
+
333
+ function create(opts) {
334
+ opts = opts || {};
335
+ var query = opts.query;
336
+ if (!query) {
337
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
338
+ }
339
+
340
+ // Pagination cursors for ordersWithTag() are HMAC-tagged via
341
+ // b.pagination so an operator can't hand-craft one to skip past a
342
+ // hidden row or replay across deployments. The secret defaults to
343
+ // a dev-only placeholder so the primitive boots in tests; production
344
+ // deploys supply a derived value (typically b.crypto.namespaceHash(
345
+ // "order-tags-cursor", D1_BRIDGE_SECRET)).
346
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
347
+ if (process.env.NODE_ENV === "production") {
348
+ throw new Error("orderTags.create: opts.cursorSecret is required in production");
349
+ }
350
+ opts.cursorSecret = "order-tags-cursor-secret-dev-only";
351
+ }
352
+ var cursorSecret = opts.cursorSecret;
353
+
354
+ async function _readTag(slug) {
355
+ var r = await query(
356
+ "SELECT slug, title, description, color, default_color_for_status, " +
357
+ "archived_at, created_at, updated_at FROM order_tags_defined " +
358
+ "WHERE slug = ?1 LIMIT 1",
359
+ [slug],
360
+ );
361
+ return r.rows[0] || null;
362
+ }
363
+
364
+ function _hydrateTag(row) {
365
+ if (!row) return null;
366
+ return {
367
+ slug: row.slug,
368
+ title: row.title,
369
+ description: row.description,
370
+ color: row.color,
371
+ default_color_for_status: row.default_color_for_status,
372
+ archived_at: row.archived_at,
373
+ created_at: row.created_at,
374
+ updated_at: row.updated_at,
375
+ };
376
+ }
377
+
378
+ async function _readActiveAssignment(orderId, tagSlug) {
379
+ var r = await query(
380
+ "SELECT id, order_id, tag_slug, applied_by, applied_at, reason, " +
381
+ "removed_at, removed_by FROM order_tag_assignments " +
382
+ "WHERE order_id = ?1 AND tag_slug = ?2 AND removed_at IS NULL LIMIT 1",
383
+ [orderId, tagSlug],
384
+ );
385
+ return r.rows[0] || null;
386
+ }
387
+
388
+ // The latest `applied_at` we've ever written for this (order, tag) pair
389
+ // across both active and removed rows. Used by the monotonic-clock
390
+ // bumper to guarantee a re-apply after removal lands strictly after
391
+ // the prior apply, even when the wall clock would tie or regress.
392
+ async function _latestAppliedAt(orderId, tagSlug) {
393
+ var r = await query(
394
+ "SELECT MAX(applied_at) AS ts FROM order_tag_assignments " +
395
+ "WHERE order_id = ?1 AND tag_slug = ?2",
396
+ [orderId, tagSlug],
397
+ );
398
+ var row = r.rows[0] || {};
399
+ return row.ts == null ? null : Number(row.ts);
400
+ }
401
+
402
+ // Two operators clicking the same tag pill within the same
403
+ // millisecond would tie on applied_at and make the
404
+ // "latest assignment for this (order, tag)" ambiguous. Bump the
405
+ // requested timestamp to `prior + 1` on collision (or when an
406
+ // out-of-order operator write would otherwise regress).
407
+ function _resolveAppliedAt(requestedTs, latestTs) {
408
+ if (latestTs == null) return requestedTs;
409
+ if (requestedTs > latestTs) return requestedTs;
410
+ return latestTs + 1;
411
+ }
412
+
413
+ async function _readRule(slug) {
414
+ var r = await query(
415
+ "SELECT slug, conditions_json, tag_slug, archived_at, created_at, updated_at " +
416
+ "FROM order_tag_rules WHERE slug = ?1 LIMIT 1",
417
+ [slug],
418
+ );
419
+ return r.rows[0] || null;
420
+ }
421
+
422
+ function _hydrateAssignment(row) {
423
+ return {
424
+ id: row.id,
425
+ order_id: row.order_id,
426
+ tag_slug: row.tag_slug,
427
+ applied_by: row.applied_by,
428
+ applied_at: row.applied_at,
429
+ reason: row.reason,
430
+ removed_at: row.removed_at,
431
+ removed_by: row.removed_by,
432
+ };
433
+ }
434
+
435
+ // Build the order-shape view a rule's conditions predicate against.
436
+ // line_count comes from order_lines; customer_segment_in walks the
437
+ // customer_segment_membership cache (the customerSegments primitive
438
+ // owns the cache; this primitive is a read-only consumer). Cancelled
439
+ // orders are still tagged-able — operator workflows include
440
+ // post-cancellation routing — so we don't filter on status here.
441
+ async function _orderShape(orderId) {
442
+ var orderRow = (await query(
443
+ "SELECT id, customer_id, grand_total_minor, ship_to_json " +
444
+ "FROM orders WHERE id = ?1 LIMIT 1",
445
+ [orderId],
446
+ )).rows[0];
447
+ if (!orderRow) return null;
448
+ var country = null;
449
+ try {
450
+ var shipTo = JSON.parse(orderRow.ship_to_json || "{}");
451
+ if (shipTo && typeof shipTo.country === "string") country = shipTo.country;
452
+ } catch (_e) { /* drop-silent — ship_to_json is operator-shaped */ }
453
+ var lineRow = (await query(
454
+ "SELECT COUNT(*) AS n FROM order_lines WHERE order_id = ?1",
455
+ [orderId],
456
+ )).rows[0] || {};
457
+ var lineCount = Number(lineRow.n || 0);
458
+ var segments = [];
459
+ if (orderRow.customer_id) {
460
+ var segRows = (await query(
461
+ "SELECT cs.slug FROM customer_segment_membership csm " +
462
+ "JOIN customer_segments cs ON cs.id = csm.segment_id " +
463
+ "WHERE csm.customer_id = ?1 AND cs.archived_at IS NULL",
464
+ [orderRow.customer_id],
465
+ )).rows;
466
+ for (var i = 0; i < segRows.length; i += 1) segments.push(segRows[i].slug);
467
+ }
468
+ return {
469
+ order_id: orderRow.id,
470
+ customer_id: orderRow.customer_id,
471
+ grand_total_minor: Number(orderRow.grand_total_minor || 0),
472
+ country: country,
473
+ line_count: lineCount,
474
+ segments: segments,
475
+ };
476
+ }
477
+
478
+ // Pure predicate evaluator: returns null on match, or a reason
479
+ // string when the rule's conditions don't all hold. Unknown keys
480
+ // never reach this function — they're rejected at defineRule time.
481
+ function _matchReason(conditions, shape) {
482
+ if (conditions.total_minor_min != null && shape.grand_total_minor < conditions.total_minor_min) {
483
+ return "total_minor_min";
484
+ }
485
+ if (conditions.total_minor_max != null && shape.grand_total_minor > conditions.total_minor_max) {
486
+ return "total_minor_max";
487
+ }
488
+ if (conditions.line_count_min != null && shape.line_count < conditions.line_count_min) {
489
+ return "line_count_min";
490
+ }
491
+ if (conditions.country_in != null) {
492
+ if (shape.country == null || conditions.country_in.indexOf(shape.country) === -1) {
493
+ return "country_in";
494
+ }
495
+ }
496
+ if (conditions.customer_segment_in != null) {
497
+ var hit = false;
498
+ for (var i = 0; i < conditions.customer_segment_in.length; i += 1) {
499
+ if (shape.segments.indexOf(conditions.customer_segment_in[i]) !== -1) {
500
+ hit = true; break;
501
+ }
502
+ }
503
+ if (!hit) return "customer_segment_in";
504
+ }
505
+ return null;
506
+ }
507
+
508
+ return {
509
+ KNOWN_CONDITION_KEYS: KNOWN_CONDITION_KEYS.slice(),
510
+ ALLOWED_STATUSES: ALLOWED_STATUSES.slice(),
511
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
512
+ MAX_LIMIT: MAX_LIMIT,
513
+
514
+ defineTag: async function (input) {
515
+ if (!input || typeof input !== "object") {
516
+ throw new TypeError("orderTags.defineTag: input object required");
517
+ }
518
+ var slug = _slug(input.slug);
519
+ var title = _title(input.title);
520
+ var desc = _description(input.description);
521
+ var color = _color(input.color);
522
+ var dcfs = _statusOrNull(input.default_color_for_status);
523
+
524
+ var existing = await _readTag(slug);
525
+ if (existing) {
526
+ var err = new Error("orderTags.defineTag: slug " + JSON.stringify(slug) + " already defined");
527
+ err.code = "ORDER_TAG_SLUG_EXISTS";
528
+ throw err;
529
+ }
530
+
531
+ var ts = _now();
532
+ await query(
533
+ "INSERT INTO order_tags_defined " +
534
+ "(slug, title, description, color, default_color_for_status, " +
535
+ " archived_at, created_at, updated_at) " +
536
+ "VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)",
537
+ [slug, title, desc, color, dcfs, ts],
538
+ );
539
+ return _hydrateTag(await _readTag(slug));
540
+ },
541
+
542
+ applyTag: async function (input) {
543
+ if (!input || typeof input !== "object") {
544
+ throw new TypeError("orderTags.applyTag: input object required");
545
+ }
546
+ var orderId = _uuid(input.order_id, "order_id");
547
+ var tagSlug = _slug(input.tag_slug, "tag_slug");
548
+ var appliedBy = _by(input.applied_by, "applied_by");
549
+ var reason = _reason(input.reason);
550
+
551
+ var tag = await _readTag(tagSlug);
552
+ if (!tag) {
553
+ throw new TypeError("orderTags.applyTag: unknown tag_slug " + JSON.stringify(tagSlug));
554
+ }
555
+ if (tag.archived_at != null) {
556
+ var earc = new Error("orderTags.applyTag: tag_slug " + JSON.stringify(tagSlug) + " is archived");
557
+ earc.code = "ORDER_TAG_ARCHIVED";
558
+ throw earc;
559
+ }
560
+
561
+ // Active assignment already present — refuse rather than
562
+ // silently re-using the existing row, so the operator sees the
563
+ // duplicate intent.
564
+ var active = await _readActiveAssignment(orderId, tagSlug);
565
+ if (active) {
566
+ var eu = new Error("orderTags.applyTag: tag " + JSON.stringify(tagSlug) +
567
+ " already active on order " + JSON.stringify(orderId));
568
+ eu.code = "ORDER_TAG_ALREADY_APPLIED";
569
+ throw eu;
570
+ }
571
+
572
+ var latestTs = await _latestAppliedAt(orderId, tagSlug);
573
+ var ts = _resolveAppliedAt(_now(), latestTs);
574
+ var id = _b().uuid.v7();
575
+ await query(
576
+ "INSERT INTO order_tag_assignments " +
577
+ "(id, order_id, tag_slug, applied_by, applied_at, reason, " +
578
+ " removed_at, removed_by) " +
579
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL)",
580
+ [id, orderId, tagSlug, appliedBy, ts, reason],
581
+ );
582
+ return _hydrateAssignment({
583
+ id: id, order_id: orderId, tag_slug: tagSlug,
584
+ applied_by: appliedBy, applied_at: ts, reason: reason,
585
+ removed_at: null, removed_by: null,
586
+ });
587
+ },
588
+
589
+ removeTag: async function (input) {
590
+ if (!input || typeof input !== "object") {
591
+ throw new TypeError("orderTags.removeTag: input object required");
592
+ }
593
+ var orderId = _uuid(input.order_id, "order_id");
594
+ var tagSlug = _slug(input.tag_slug, "tag_slug");
595
+ var removedBy = _by(input.removed_by, "removed_by");
596
+
597
+ var active = await _readActiveAssignment(orderId, tagSlug);
598
+ if (!active) {
599
+ var en = new Error("orderTags.removeTag: tag " + JSON.stringify(tagSlug) +
600
+ " not active on order " + JSON.stringify(orderId));
601
+ en.code = "ORDER_TAG_NOT_ACTIVE";
602
+ throw en;
603
+ }
604
+ // removed_at must land strictly after applied_at so the audit
605
+ // timeline never folds an apply + remove into the same instant.
606
+ var rawNow = _now();
607
+ var ts = rawNow > active.applied_at ? rawNow : active.applied_at + 1;
608
+ await query(
609
+ "UPDATE order_tag_assignments SET removed_at = ?1, removed_by = ?2 " +
610
+ "WHERE id = ?3",
611
+ [ts, removedBy, active.id],
612
+ );
613
+ return {
614
+ id: active.id,
615
+ order_id: orderId,
616
+ tag_slug: tagSlug,
617
+ applied_by: active.applied_by,
618
+ applied_at: active.applied_at,
619
+ reason: active.reason,
620
+ removed_at: ts,
621
+ removed_by: removedBy,
622
+ };
623
+ },
624
+
625
+ tagsForOrder: async function (orderId) {
626
+ _uuid(orderId, "order_id");
627
+ var r = await query(
628
+ "SELECT a.id, a.order_id, a.tag_slug, a.applied_by, a.applied_at, " +
629
+ "a.reason, t.title, t.description, t.color, t.default_color_for_status, " +
630
+ "t.archived_at AS tag_archived_at " +
631
+ "FROM order_tag_assignments a " +
632
+ "JOIN order_tags_defined t ON t.slug = a.tag_slug " +
633
+ "WHERE a.order_id = ?1 AND a.removed_at IS NULL " +
634
+ "ORDER BY a.applied_at ASC, a.tag_slug ASC",
635
+ [orderId],
636
+ );
637
+ var rows = r.rows;
638
+ var out = [];
639
+ for (var i = 0; i < rows.length; i += 1) {
640
+ var row = rows[i];
641
+ out.push({
642
+ assignment_id: row.id,
643
+ order_id: row.order_id,
644
+ tag_slug: row.tag_slug,
645
+ title: row.title,
646
+ description: row.description,
647
+ color: row.color,
648
+ default_color_for_status: row.default_color_for_status,
649
+ applied_by: row.applied_by,
650
+ applied_at: row.applied_at,
651
+ reason: row.reason,
652
+ tag_archived: row.tag_archived_at != null,
653
+ });
654
+ }
655
+ return out;
656
+ },
657
+
658
+ ordersWithTag: async function (input) {
659
+ if (!input || typeof input !== "object") {
660
+ throw new TypeError("orderTags.ordersWithTag: input object required");
661
+ }
662
+ var tagSlug = _slug(input.tag_slug, "tag_slug");
663
+ var limit = _limit(input.limit, "limit");
664
+
665
+ var cursorVal = null;
666
+ if (input.cursor != null) {
667
+ if (typeof input.cursor !== "string") {
668
+ throw new TypeError("orderTags.ordersWithTag: cursor must be an opaque string or null");
669
+ }
670
+ try {
671
+ var state = _b().pagination.decodeCursor(input.cursor, cursorSecret);
672
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(ORDER_KEY)) {
673
+ throw new TypeError("orderTags.ordersWithTag: cursor orderKey mismatch");
674
+ }
675
+ cursorVal = state.vals[0];
676
+ } catch (e) {
677
+ if (e instanceof TypeError) throw e;
678
+ throw new TypeError("orderTags.ordersWithTag: cursor — " + (e && e.message || "malformed"));
679
+ }
680
+ }
681
+
682
+ var sql, params;
683
+ if (cursorVal != null) {
684
+ sql = "SELECT order_id FROM order_tag_assignments " +
685
+ "WHERE tag_slug = ?1 AND removed_at IS NULL AND order_id > ?2 " +
686
+ "ORDER BY order_id ASC LIMIT ?3";
687
+ params = [tagSlug, cursorVal, limit];
688
+ } else {
689
+ sql = "SELECT order_id FROM order_tag_assignments " +
690
+ "WHERE tag_slug = ?1 AND removed_at IS NULL " +
691
+ "ORDER BY order_id ASC LIMIT ?2";
692
+ params = [tagSlug, limit];
693
+ }
694
+ var rows = (await query(sql, params)).rows;
695
+ var ids = [];
696
+ for (var i = 0; i < rows.length; i += 1) ids.push(rows[i].order_id);
697
+ var nextCursor = null;
698
+ if (ids.length === limit) {
699
+ nextCursor = _b().pagination.encodeCursor({
700
+ orderKey: ORDER_KEY,
701
+ vals: [ids[ids.length - 1]],
702
+ forward: true,
703
+ }, cursorSecret);
704
+ }
705
+ return { order_ids: ids, next_cursor: nextCursor };
706
+ },
707
+
708
+ popularTags: async function (input) {
709
+ input = input || {};
710
+ var limit = _limit(input.limit, "limit");
711
+ var r = await query(
712
+ "SELECT a.tag_slug, COUNT(*) AS n, t.title, t.color " +
713
+ "FROM order_tag_assignments a " +
714
+ "JOIN order_tags_defined t ON t.slug = a.tag_slug " +
715
+ "WHERE a.removed_at IS NULL AND t.archived_at IS NULL " +
716
+ "GROUP BY a.tag_slug, t.title, t.color " +
717
+ "ORDER BY n DESC, a.tag_slug ASC " +
718
+ "LIMIT ?1",
719
+ [limit],
720
+ );
721
+ var rows = r.rows;
722
+ var out = [];
723
+ for (var i = 0; i < rows.length; i += 1) {
724
+ out.push({
725
+ tag_slug: rows[i].tag_slug,
726
+ title: rows[i].title,
727
+ color: rows[i].color,
728
+ count: Number(rows[i].n || 0),
729
+ });
730
+ }
731
+ return out;
732
+ },
733
+
734
+ defineRule: async function (input) {
735
+ if (!input || typeof input !== "object") {
736
+ throw new TypeError("orderTags.defineRule: input object required");
737
+ }
738
+ var slug = _slug(input.slug, "rule slug");
739
+ var conds = _validateConditions(input.conditions);
740
+ var tagSlug = _slug(input.tag_slug, "tag_slug");
741
+
742
+ var existing = await _readRule(slug);
743
+ if (existing) {
744
+ var err = new Error("orderTags.defineRule: rule slug " + JSON.stringify(slug) + " already defined");
745
+ err.code = "ORDER_TAG_RULE_SLUG_EXISTS";
746
+ throw err;
747
+ }
748
+
749
+ var tag = await _readTag(tagSlug);
750
+ if (!tag) {
751
+ throw new TypeError("orderTags.defineRule: tag_slug " + JSON.stringify(tagSlug) + " not defined");
752
+ }
753
+
754
+ var ts = _now();
755
+ await query(
756
+ "INSERT INTO order_tag_rules " +
757
+ "(slug, conditions_json, tag_slug, archived_at, created_at, updated_at) " +
758
+ "VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
759
+ [slug, JSON.stringify(conds), tagSlug, ts],
760
+ );
761
+ return {
762
+ slug: slug,
763
+ conditions: conds,
764
+ tag_slug: tagSlug,
765
+ archived_at: null,
766
+ created_at: ts,
767
+ updated_at: ts,
768
+ };
769
+ },
770
+
771
+ applyRulesToOrder: async function (orderId) {
772
+ _uuid(orderId, "order_id");
773
+ var shape = await _orderShape(orderId);
774
+ if (!shape) {
775
+ throw new TypeError("orderTags.applyRulesToOrder: unknown order_id " + JSON.stringify(orderId));
776
+ }
777
+
778
+ var rules = (await query(
779
+ "SELECT slug, conditions_json, tag_slug FROM order_tag_rules " +
780
+ "WHERE archived_at IS NULL ORDER BY slug ASC",
781
+ [],
782
+ )).rows;
783
+
784
+ var applied = [];
785
+ var skipped = [];
786
+ for (var i = 0; i < rules.length; i += 1) {
787
+ var rule = rules[i];
788
+ var conds;
789
+ try { conds = JSON.parse(rule.conditions_json); }
790
+ catch (_e) {
791
+ skipped.push({ rule_slug: rule.slug, reason: "conditions_unparseable" });
792
+ continue;
793
+ }
794
+ var missReason = _matchReason(conds, shape);
795
+ if (missReason != null) {
796
+ skipped.push({ rule_slug: rule.slug, reason: missReason });
797
+ continue;
798
+ }
799
+ // Tag definition must exist + be active to apply.
800
+ var tag = await _readTag(rule.tag_slug);
801
+ if (!tag || tag.archived_at != null) {
802
+ skipped.push({ rule_slug: rule.slug, reason: "tag_unavailable" });
803
+ continue;
804
+ }
805
+ // Idempotent — skip when already actively applied.
806
+ var active = await _readActiveAssignment(orderId, rule.tag_slug);
807
+ if (active) {
808
+ skipped.push({ rule_slug: rule.slug, reason: "already_applied" });
809
+ continue;
810
+ }
811
+ var latestTs = await _latestAppliedAt(orderId, rule.tag_slug);
812
+ var ts = _resolveAppliedAt(_now(), latestTs);
813
+ var id = _b().uuid.v7();
814
+ await query(
815
+ "INSERT INTO order_tag_assignments " +
816
+ "(id, order_id, tag_slug, applied_by, applied_at, reason, " +
817
+ " removed_at, removed_by) " +
818
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, NULL)",
819
+ [id, orderId, rule.tag_slug, "rule:" + rule.slug, ts, "auto-applied by rule " + rule.slug],
820
+ );
821
+ applied.push(rule.tag_slug);
822
+ }
823
+ return { applied: applied, skipped: skipped };
824
+ },
825
+
826
+ listTags: async function (input) {
827
+ input = input || {};
828
+ var includeArchived = !!input.include_archived;
829
+ var sql = "SELECT slug, title, description, color, default_color_for_status, " +
830
+ "archived_at, created_at, updated_at FROM order_tags_defined";
831
+ if (!includeArchived) sql += " WHERE archived_at IS NULL";
832
+ sql += " ORDER BY slug ASC";
833
+ var rows = (await query(sql, [])).rows;
834
+ var out = [];
835
+ for (var i = 0; i < rows.length; i += 1) out.push(_hydrateTag(rows[i]));
836
+ return out;
837
+ },
838
+
839
+ archiveTag: async function (slug) {
840
+ _slug(slug);
841
+ var existing = await _readTag(slug);
842
+ if (!existing) {
843
+ throw new TypeError("orderTags.archiveTag: unknown slug " + JSON.stringify(slug));
844
+ }
845
+ if (existing.archived_at != null) {
846
+ return _hydrateTag(existing);
847
+ }
848
+ var ts = _now();
849
+ await query(
850
+ "UPDATE order_tags_defined SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
851
+ [ts, slug],
852
+ );
853
+ return _hydrateTag(await _readTag(slug));
854
+ },
855
+
856
+ updateTag: async function (slug, patch) {
857
+ _slug(slug);
858
+ if (!patch || typeof patch !== "object") {
859
+ throw new TypeError("orderTags.updateTag: patch object required");
860
+ }
861
+ var existing = await _readTag(slug);
862
+ if (!existing) {
863
+ throw new TypeError("orderTags.updateTag: unknown slug " + JSON.stringify(slug));
864
+ }
865
+
866
+ var nextTitle = existing.title;
867
+ var nextDesc = existing.description;
868
+ var nextColor = existing.color;
869
+ var nextDcfs = existing.default_color_for_status;
870
+
871
+ if (Object.prototype.hasOwnProperty.call(patch, "title")) {
872
+ nextTitle = _title(patch.title);
873
+ }
874
+ if (Object.prototype.hasOwnProperty.call(patch, "description")) {
875
+ nextDesc = _description(patch.description);
876
+ }
877
+ if (Object.prototype.hasOwnProperty.call(patch, "color")) {
878
+ nextColor = _color(patch.color);
879
+ }
880
+ if (Object.prototype.hasOwnProperty.call(patch, "default_color_for_status")) {
881
+ nextDcfs = _statusOrNull(patch.default_color_for_status);
882
+ }
883
+
884
+ var ts = _now();
885
+ await query(
886
+ "UPDATE order_tags_defined SET title = ?1, description = ?2, color = ?3, " +
887
+ "default_color_for_status = ?4, updated_at = ?5 WHERE slug = ?6",
888
+ [nextTitle, nextDesc, nextColor, nextDcfs, ts, slug],
889
+ );
890
+ return _hydrateTag(await _readTag(slug));
891
+ },
892
+
893
+ historyForOrder: async function (orderId) {
894
+ _uuid(orderId, "order_id");
895
+ var r = await query(
896
+ "SELECT id, order_id, tag_slug, applied_by, applied_at, reason, " +
897
+ "removed_at, removed_by FROM order_tag_assignments " +
898
+ "WHERE order_id = ?1 ORDER BY applied_at ASC, tag_slug ASC",
899
+ [orderId],
900
+ );
901
+ var rows = r.rows;
902
+ var events = [];
903
+ for (var i = 0; i < rows.length; i += 1) {
904
+ var row = rows[i];
905
+ events.push({
906
+ kind: "apply",
907
+ assignment_id: row.id,
908
+ tag_slug: row.tag_slug,
909
+ by: row.applied_by,
910
+ at: row.applied_at,
911
+ reason: row.reason,
912
+ });
913
+ if (row.removed_at != null) {
914
+ events.push({
915
+ kind: "remove",
916
+ assignment_id: row.id,
917
+ tag_slug: row.tag_slug,
918
+ by: row.removed_by,
919
+ at: row.removed_at,
920
+ reason: "",
921
+ });
922
+ }
923
+ }
924
+ events.sort(function (a, b) {
925
+ if (a.at !== b.at) return a.at - b.at;
926
+ if (a.tag_slug !== b.tag_slug) return a.tag_slug < b.tag_slug ? -1 : 1;
927
+ // apply before remove on identical timestamps (the monotonic
928
+ // bump guarantees this won't happen for the same assignment
929
+ // row, but two assignments racing at the same ms could).
930
+ if (a.kind !== b.kind) return a.kind === "apply" ? -1 : 1;
931
+ return 0;
932
+ });
933
+ return events;
934
+ },
935
+ };
936
+ }
937
+
938
+ module.exports = {
939
+ create: create,
940
+ KNOWN_CONDITION_KEYS: KNOWN_CONDITION_KEYS,
941
+ ALLOWED_STATUSES: ALLOWED_STATUSES,
942
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
943
+ MAX_LIMIT: MAX_LIMIT,
944
+ };