@blamejs/blamejs-shop 0.0.72 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,602 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.orderExchanges
4
+ * @title Order exchanges — customer-requested item swap
5
+ *
6
+ * @intro
7
+ * A customer wants a different item instead of a refund. They
8
+ * ship the original merchandise back; the operator ships a
9
+ * replacement out. The two arms of the swap progress
10
+ * independently — the customer's return may reach the warehouse
11
+ * before OR after the replacement reaches the customer — so the
12
+ * FSM tolerates both orderings and closeExchange collapses them
13
+ * into the terminal `closed` state once both sides land.
14
+ *
15
+ * Distinct from `returns`: a return ends in a refund (money flows
16
+ * one way). An exchange ends in two physical movements (customer
17
+ * ships X back, operator ships Y out) and no monetary refund.
18
+ * Operators choosing between the two surfaces decide at request
19
+ * time which lifecycle to run; this primitive does NOT collapse
20
+ * to a returns row internally.
21
+ *
22
+ * FSM:
23
+ *
24
+ * pending --approveExchange--> approved --markReplacementShipped--> shipped
25
+ * \ \ |
26
+ * --rejectExchange--> rejected --rejectExchange--> rejected |
27
+ * v
28
+ * +---markReplacementDelivered--+
29
+ * | |
30
+ * v v
31
+ * delivered received
32
+ * \ (markReturnReceived
33
+ * \ from shipped)
34
+ * +--closeExchange (both sides done) --> closed
35
+ *
36
+ * Composition:
37
+ * - `returns` — optional. When wired, the
38
+ * primitive's `request` step is offered as a peer surface for
39
+ * operators who prefer to author a returns RMA alongside the
40
+ * exchange (the link lives on the request layer; this
41
+ * primitive doesn't auto-create the RMA).
42
+ * - `order` — optional. When wired,
43
+ * `closeExchange` may surface the parent order's lines for
44
+ * audit-context (the primitive does NOT mutate the order FSM
45
+ * — an exchange's resolution does not by itself transition
46
+ * the order; the operator decides whether to drive the order
47
+ * state via `order.transition`).
48
+ * - `inventoryAllocations` — optional. When wired,
49
+ * `approveExchange` opens a hold on `replacement_sku` (+
50
+ * `replacement_variant_id`) so the replacement is pinned to
51
+ * the warehouse shelf before shipping. A hold failure rolls
52
+ * back the approval. Absent the handle, the operator owns
53
+ * inventory reservation out-of-band.
54
+ * - `query` — optional D1-shaped query handle;
55
+ * falls back to `b.externalDb.query` when omitted.
56
+ *
57
+ * @primitive orderExchanges
58
+ * @related b.uuid, b.guardUuid, returns, order, inventoryAllocations
59
+ */
60
+
61
+ var bShop;
62
+ function _b() {
63
+ if (!bShop) bShop = require("./index");
64
+ return bShop.framework;
65
+ }
66
+
67
+ // ---- constants ----------------------------------------------------------
68
+
69
+ var REASONS = Object.freeze([
70
+ "defective",
71
+ "wrong-item",
72
+ "wrong-size",
73
+ "wrong-colour",
74
+ "damaged-in-transit",
75
+ "not-as-described",
76
+ "other",
77
+ ]);
78
+
79
+ var STATUSES = Object.freeze([
80
+ "pending", "approved", "shipped", "delivered", "received", "closed", "rejected",
81
+ ]);
82
+
83
+ var TERMINAL_STATES = Object.freeze(["closed", "rejected"]);
84
+
85
+ // FSM transition graph. Each (currentStatus, event) pair maps to a
86
+ // next state, or is absent when the transition is illegal. The
87
+ // closed terminal is reached via `closeExchange` from EITHER
88
+ // `delivered` or `received` — `closeExchange` then verifies both
89
+ // sides actually completed (delivered_at AND returned_at both
90
+ // non-null) before flipping the row.
91
+ var TRANSITIONS = {
92
+ pending: { approveExchange: "approved", rejectExchange: "rejected" },
93
+ approved: { markReplacementShipped: "shipped", rejectExchange: "rejected" },
94
+ shipped: { markReplacementDelivered: "delivered", markReturnReceived: "received" },
95
+ delivered: { markReturnReceived: "received", closeExchange: "closed" },
96
+ received: { markReplacementDelivered: "delivered", closeExchange: "closed" },
97
+ closed: {},
98
+ rejected: {},
99
+ };
100
+
101
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
102
+ var MAX_REJECT_REASON = 1024;
103
+ var MAX_TRACKING_LEN = 128;
104
+ var MAX_CARRIER_LEN = 64;
105
+
106
+ // ---- validators ---------------------------------------------------------
107
+
108
+ function _uuid(s, label) {
109
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
110
+ catch (e) { throw new TypeError("order-exchanges: " + label + " — " + (e && e.message || "invalid UUID")); }
111
+ }
112
+ function _maybeUuid(s, label) {
113
+ if (s == null) return null;
114
+ return _uuid(s, label);
115
+ }
116
+ function _sku(s, label) {
117
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
118
+ throw new TypeError("order-exchanges: " + label +
119
+ " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
120
+ }
121
+ return s;
122
+ }
123
+ function _reason(r) {
124
+ if (typeof r !== "string" || REASONS.indexOf(r) === -1) {
125
+ throw new TypeError("order-exchanges: reason must be one of " + REASONS.join(", "));
126
+ }
127
+ return r;
128
+ }
129
+ function _status(s) {
130
+ if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
131
+ throw new TypeError("order-exchanges: status must be one of " + STATUSES.join(", "));
132
+ }
133
+ return s;
134
+ }
135
+ function _positiveInt(n, label) {
136
+ if (!Number.isInteger(n) || n <= 0) {
137
+ throw new TypeError("order-exchanges: " + label + " must be a positive integer");
138
+ }
139
+ return n;
140
+ }
141
+ function _ts(n, label) {
142
+ if (!Number.isInteger(n) || n <= 0) {
143
+ throw new TypeError("order-exchanges: " + label + " must be a positive integer (epoch ms)");
144
+ }
145
+ return n;
146
+ }
147
+ function _boundedString(s, label, maxLen) {
148
+ if (typeof s !== "string" || !s.length) {
149
+ throw new TypeError("order-exchanges: " + label + " must be a non-empty string");
150
+ }
151
+ if (s.length > maxLen) {
152
+ throw new TypeError("order-exchanges: " + label + " must be <= " + maxLen + " characters");
153
+ }
154
+ // Refuse control bytes — same posture as other free-form text
155
+ // columns in this framework.
156
+ if (/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/.test(s)) {
157
+ throw new TypeError("order-exchanges: " + label + " contains control bytes");
158
+ }
159
+ return s;
160
+ }
161
+
162
+ // ---- factory ------------------------------------------------------------
163
+
164
+ function create(opts) {
165
+ opts = opts || {};
166
+ var query = opts.query;
167
+ if (!query) {
168
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
169
+ }
170
+
171
+ // returns is optional. When wired, callers can author a sibling
172
+ // RMA at request time via the returned handle; this primitive
173
+ // does NOT auto-create the RMA — operators decide whether the
174
+ // exchange supersedes a returns lifecycle entirely or runs
175
+ // alongside one. Shape check ensures `.request` exists when the
176
+ // handle IS provided.
177
+ var returnsPrim = opts.returns || null;
178
+ if (returnsPrim && typeof returnsPrim.request !== "function") {
179
+ throw new TypeError("order-exchanges.create: opts.returns must expose a request(input) method");
180
+ }
181
+
182
+ // order is optional. When wired, it's available for audit-context
183
+ // reads only — this primitive does NOT mutate the order FSM. The
184
+ // operator drives the order's own status (e.g. `partially_returned`)
185
+ // through `order.transition` if their lifecycle calls for it.
186
+ var orderPrim = opts.order || null;
187
+ if (orderPrim && typeof orderPrim.get !== "function") {
188
+ throw new TypeError("order-exchanges.create: opts.order must expose a get(id) method");
189
+ }
190
+
191
+ // inventoryAllocations is optional. When wired,
192
+ // approveExchange opens a hold on the replacement SKU so the
193
+ // replacement is pinned to a warehouse shelf at approval time.
194
+ // The handle's holdForCart is reused (the hold is keyed by the
195
+ // exchange id, surfaced through the `cart_id` slot — operators
196
+ // who run a strict cart-id-must-be-a-cart shape filter the hold
197
+ // out via the metadata). A hold failure rolls back the approval.
198
+ var invAllocs = opts.inventoryAllocations || null;
199
+ if (invAllocs && typeof invAllocs.holdForCart !== "function") {
200
+ throw new TypeError("order-exchanges.create: opts.inventoryAllocations must expose a holdForCart(input) method");
201
+ }
202
+
203
+ // ---- monotonic clock --------------------------------------------------
204
+ //
205
+ // Operator-driven FSM transitions can land in the same wall-clock
206
+ // millisecond on fast machines (a request immediately followed by
207
+ // an approve in a test, for instance). Bumping by 1ms on a tie keeps
208
+ // the timeline strictly increasing so a sort-by-timestamp read returns
209
+ // the events in the order they were issued.
210
+ var _lastTs = 0;
211
+ function _monotonicTs() {
212
+ var wall = Date.now();
213
+ if (wall > _lastTs) _lastTs = wall;
214
+ else _lastTs += 1;
215
+ return _lastTs;
216
+ }
217
+
218
+ async function _getRow(id) {
219
+ var r = await query("SELECT * FROM order_exchanges WHERE id = ?1", [id]);
220
+ return r.rows.length ? r.rows[0] : null;
221
+ }
222
+
223
+ function _assertTransition(currentStatus, event) {
224
+ var allowed = TRANSITIONS[currentStatus];
225
+ if (!allowed || !allowed[event]) {
226
+ var err = new Error(
227
+ "order-exchanges: transition '" + event + "' refused from state '" + currentStatus + "'"
228
+ );
229
+ err.code = "EXCHANGE_TRANSITION_REFUSED";
230
+ throw err;
231
+ }
232
+ return allowed[event];
233
+ }
234
+
235
+ return {
236
+ REASONS: REASONS,
237
+ STATUSES: STATUSES,
238
+ TERMINAL_STATES: TERMINAL_STATES,
239
+
240
+ // Open a new exchange. Lands in `pending` awaiting operator
241
+ // review. Refuses when reason / SKUs / quantities are malformed
242
+ // and when the order_id / line_id are not strict UUIDs.
243
+ requestExchange: async function (input) {
244
+ if (!input || typeof input !== "object") {
245
+ throw new TypeError("order-exchanges.requestExchange: input object required");
246
+ }
247
+ var orderId = _uuid(input.order_id, "order_id");
248
+ var lineId = _uuid(input.line_id, "line_id");
249
+ _sku(input.return_sku, "return_sku");
250
+ _sku(input.replacement_sku, "replacement_sku");
251
+ _positiveInt(input.return_qty, "return_qty");
252
+ _positiveInt(input.replacement_qty, "replacement_qty");
253
+ _reason(input.reason);
254
+ var replacementVariantId = _maybeUuid(input.replacement_variant_id, "replacement_variant_id");
255
+
256
+ var id = _b().uuid.v7();
257
+ var ts = _monotonicTs();
258
+ await query(
259
+ "INSERT INTO order_exchanges " +
260
+ "(id, order_id, line_id, return_sku, return_qty, " +
261
+ "replacement_sku, replacement_variant_id, replacement_qty, " +
262
+ "reason, status, created_at, updated_at) " +
263
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 'pending', ?10, ?10)",
264
+ [
265
+ id, orderId, lineId, input.return_sku, input.return_qty,
266
+ input.replacement_sku, replacementVariantId, input.replacement_qty,
267
+ input.reason, ts,
268
+ ],
269
+ );
270
+ return await _getRow(id);
271
+ },
272
+
273
+ // pending -> approved. Records the operator id. When
274
+ // inventoryAllocations is wired, opens a hold on the replacement
275
+ // SKU so the replacement is pinned to a warehouse shelf; a hold
276
+ // failure surfaces to the caller and the approval is NOT
277
+ // recorded (the DB row stays pending so a retry decides next).
278
+ approveExchange: async function (exchangeId, input) {
279
+ _uuid(exchangeId, "exchange id");
280
+ if (!input || typeof input !== "object") {
281
+ throw new TypeError("order-exchanges.approveExchange: input object required");
282
+ }
283
+ var approverId = _uuid(input.approver_id, "approver_id");
284
+
285
+ var existing = await _getRow(exchangeId);
286
+ if (!existing) {
287
+ var miss = new TypeError("order-exchanges.approveExchange: exchange " + exchangeId + " not found");
288
+ miss.code = "EXCHANGE_NOT_FOUND";
289
+ throw miss;
290
+ }
291
+ _assertTransition(existing.status, "approveExchange");
292
+
293
+ // Pin the replacement shelf BEFORE flipping the DB row. A
294
+ // hold failure (insufficient stock, unknown location)
295
+ // surfaces to the caller; the row stays pending so the next
296
+ // operator attempt decides whether to retry, source from a
297
+ // different shelf, or reject the exchange.
298
+ if (invAllocs) {
299
+ await invAllocs.holdForCart({
300
+ cart_id: existing.id,
301
+ sku: existing.replacement_sku,
302
+ variant_id: existing.replacement_variant_id || null,
303
+ quantity: existing.replacement_qty,
304
+ ttl_seconds: input.hold_ttl_seconds || 86400,
305
+ });
306
+ }
307
+
308
+ var ts = _monotonicTs();
309
+ await query(
310
+ "UPDATE order_exchanges SET status = 'approved', approver_id = ?1, updated_at = ?2 " +
311
+ "WHERE id = ?3",
312
+ [approverId, ts, exchangeId],
313
+ );
314
+ return await _getRow(exchangeId);
315
+ },
316
+
317
+ // pending|approved -> rejected. Terminal. Records the operator
318
+ // id + the reason surfaced to the customer.
319
+ rejectExchange: async function (exchangeId, input) {
320
+ _uuid(exchangeId, "exchange id");
321
+ if (!input || typeof input !== "object") {
322
+ throw new TypeError("order-exchanges.rejectExchange: input object required");
323
+ }
324
+ var approverId = _uuid(input.approver_id, "approver_id");
325
+ var rejectReason = _boundedString(input.reject_reason, "reject_reason", MAX_REJECT_REASON);
326
+
327
+ var existing = await _getRow(exchangeId);
328
+ if (!existing) {
329
+ var miss = new TypeError("order-exchanges.rejectExchange: exchange " + exchangeId + " not found");
330
+ miss.code = "EXCHANGE_NOT_FOUND";
331
+ throw miss;
332
+ }
333
+ _assertTransition(existing.status, "rejectExchange");
334
+
335
+ var ts = _monotonicTs();
336
+ await query(
337
+ "UPDATE order_exchanges SET status = 'rejected', approver_id = ?1, reject_reason = ?2, " +
338
+ "updated_at = ?3 WHERE id = ?4",
339
+ [approverId, rejectReason, ts, exchangeId],
340
+ );
341
+ return await _getRow(exchangeId);
342
+ },
343
+
344
+ // approved -> shipped. Captures tracking_number + carrier so
345
+ // the storefront's order-detail page can render a tracking
346
+ // link without joining a separate shipments table.
347
+ markReplacementShipped: async function (exchangeId, input) {
348
+ _uuid(exchangeId, "exchange id");
349
+ if (!input || typeof input !== "object") {
350
+ throw new TypeError("order-exchanges.markReplacementShipped: input object required");
351
+ }
352
+ var tracking = _boundedString(input.tracking_number, "tracking_number", MAX_TRACKING_LEN);
353
+ var carrier = _boundedString(input.carrier, "carrier", MAX_CARRIER_LEN);
354
+ var shippedAt = input.shipped_at == null ? null : _ts(input.shipped_at, "shipped_at");
355
+
356
+ var existing = await _getRow(exchangeId);
357
+ if (!existing) {
358
+ var miss = new TypeError("order-exchanges.markReplacementShipped: exchange " + exchangeId + " not found");
359
+ miss.code = "EXCHANGE_NOT_FOUND";
360
+ throw miss;
361
+ }
362
+ _assertTransition(existing.status, "markReplacementShipped");
363
+
364
+ var ts = _monotonicTs();
365
+ var stampedShippedAt = shippedAt == null ? ts : shippedAt;
366
+ await query(
367
+ "UPDATE order_exchanges SET status = 'shipped', tracking_number = ?1, carrier = ?2, " +
368
+ "shipped_at = ?3, updated_at = ?4 WHERE id = ?5",
369
+ [tracking, carrier, stampedShippedAt, ts, exchangeId],
370
+ );
371
+ return await _getRow(exchangeId);
372
+ },
373
+
374
+ // shipped|received -> delivered. Records when the replacement
375
+ // reached the customer. From `received` (the customer's return
376
+ // already landed), the next legal step is `closeExchange`.
377
+ markReplacementDelivered: async function (exchangeId, input) {
378
+ _uuid(exchangeId, "exchange id");
379
+ input = input || {};
380
+ var deliveredAt = input.delivered_at == null ? null : _ts(input.delivered_at, "delivered_at");
381
+
382
+ var existing = await _getRow(exchangeId);
383
+ if (!existing) {
384
+ var miss = new TypeError("order-exchanges.markReplacementDelivered: exchange " + exchangeId + " not found");
385
+ miss.code = "EXCHANGE_NOT_FOUND";
386
+ throw miss;
387
+ }
388
+ var nextStatus = _assertTransition(existing.status, "markReplacementDelivered");
389
+
390
+ var ts = _monotonicTs();
391
+ var stamped = deliveredAt == null ? ts : deliveredAt;
392
+ await query(
393
+ "UPDATE order_exchanges SET status = ?1, delivered_at = ?2, updated_at = ?3 " +
394
+ "WHERE id = ?4",
395
+ [nextStatus, stamped, ts, exchangeId],
396
+ );
397
+ return await _getRow(exchangeId);
398
+ },
399
+
400
+ // shipped|delivered -> received. Records when the customer's
401
+ // return reached the warehouse. From `delivered` (the
402
+ // replacement is already with the customer), the next legal
403
+ // step is `closeExchange`.
404
+ markReturnReceived: async function (exchangeId, input) {
405
+ _uuid(exchangeId, "exchange id");
406
+ input = input || {};
407
+ var returnedAt = input.returned_at == null ? null : _ts(input.returned_at, "returned_at");
408
+
409
+ var existing = await _getRow(exchangeId);
410
+ if (!existing) {
411
+ var miss = new TypeError("order-exchanges.markReturnReceived: exchange " + exchangeId + " not found");
412
+ miss.code = "EXCHANGE_NOT_FOUND";
413
+ throw miss;
414
+ }
415
+ var nextStatus = _assertTransition(existing.status, "markReturnReceived");
416
+
417
+ var ts = _monotonicTs();
418
+ var stamped = returnedAt == null ? ts : returnedAt;
419
+ await query(
420
+ "UPDATE order_exchanges SET status = ?1, returned_at = ?2, updated_at = ?3 " +
421
+ "WHERE id = ?4",
422
+ [nextStatus, stamped, ts, exchangeId],
423
+ );
424
+ return await _getRow(exchangeId);
425
+ },
426
+
427
+ // delivered|received -> closed. The terminal collapse. Refuses
428
+ // until BOTH delivered_at AND returned_at are non-null — the
429
+ // FSM allows the transition from either parent state, but the
430
+ // physical reality of "the exchange is complete" requires both
431
+ // movements to have actually happened. Operators who need to
432
+ // close out a half-complete exchange (e.g. customer kept the
433
+ // replacement AND the original) drive `rejectExchange` from
434
+ // pre-approval or `markReturnReceived` + `markReplacementDelivered`
435
+ // by manually back-filling the timestamps.
436
+ closeExchange: async function (exchangeId, input) {
437
+ _uuid(exchangeId, "exchange id");
438
+ input = input || {};
439
+ var closedAt = input.closed_at == null ? null : _ts(input.closed_at, "closed_at");
440
+
441
+ var existing = await _getRow(exchangeId);
442
+ if (!existing) {
443
+ var miss = new TypeError("order-exchanges.closeExchange: exchange " + exchangeId + " not found");
444
+ miss.code = "EXCHANGE_NOT_FOUND";
445
+ throw miss;
446
+ }
447
+ _assertTransition(existing.status, "closeExchange");
448
+ if (existing.delivered_at == null || existing.returned_at == null) {
449
+ var incomplete = new Error(
450
+ "order-exchanges.closeExchange: cannot close — replacement delivered_at and customer returned_at must both be set " +
451
+ "(delivered_at=" + existing.delivered_at + ", returned_at=" + existing.returned_at + ")"
452
+ );
453
+ incomplete.code = "EXCHANGE_BOTH_SIDES_REQUIRED";
454
+ throw incomplete;
455
+ }
456
+
457
+ var ts = _monotonicTs();
458
+ var stamped = closedAt == null ? ts : closedAt;
459
+ await query(
460
+ "UPDATE order_exchanges SET status = 'closed', closed_at = ?1, updated_at = ?2 " +
461
+ "WHERE id = ?3",
462
+ [stamped, ts, exchangeId],
463
+ );
464
+ return await _getRow(exchangeId);
465
+ },
466
+
467
+ // Single-row read. Returns null on miss so the caller can map
468
+ // cleanly to HTTP 404.
469
+ getExchange: async function (exchangeId) {
470
+ _uuid(exchangeId, "exchange id");
471
+ return await _getRow(exchangeId);
472
+ },
473
+
474
+ // Every exchange against a given order. Ordered created_at DESC
475
+ // so the operator sees the newest claim first.
476
+ exchangesForOrder: async function (orderId) {
477
+ _uuid(orderId, "order_id");
478
+ var r = await query(
479
+ "SELECT * FROM order_exchanges WHERE order_id = ?1 " +
480
+ "ORDER BY created_at DESC, id DESC",
481
+ [orderId],
482
+ );
483
+ return r.rows;
484
+ },
485
+
486
+ // Every exchange opened against any order belonging to a
487
+ // customer. Reads through the injected `order` primitive's
488
+ // `listForCustomer` to resolve the customer→order linkage;
489
+ // refuses when `order` is not wired (this primitive does NOT
490
+ // duplicate the customer→order index).
491
+ exchangesForCustomer: async function (customerId, listOpts) {
492
+ _uuid(customerId, "customer_id");
493
+ if (!orderPrim || typeof orderPrim.listForCustomer !== "function") {
494
+ throw new TypeError("order-exchanges.exchangesForCustomer: opts.order must be wired (and expose listForCustomer) for this read");
495
+ }
496
+ listOpts = listOpts || {};
497
+ var page = await orderPrim.listForCustomer(customerId, { limit: 100 });
498
+ var orders = (page && page.rows) || [];
499
+ if (!orders.length) return [];
500
+ var placeholders = [];
501
+ var params = [];
502
+ for (var i = 0; i < orders.length; i += 1) {
503
+ placeholders.push("?" + (i + 1));
504
+ params.push(orders[i].id);
505
+ }
506
+ var sql = "SELECT * FROM order_exchanges WHERE order_id IN (" +
507
+ placeholders.join(", ") + ") ORDER BY created_at DESC, id DESC";
508
+ return (await query(sql, params)).rows;
509
+ },
510
+
511
+ // Operator queue: every exchange not yet in a terminal state.
512
+ // Ordered by created_at ASC so the oldest claim surfaces first
513
+ // (FIFO — the customer who's been waiting longest gets the
514
+ // operator's next attention).
515
+ openExchanges: async function (listOpts) {
516
+ listOpts = listOpts || {};
517
+ var statusFilter = null;
518
+ if (listOpts.status != null) {
519
+ statusFilter = _status(listOpts.status);
520
+ if (TERMINAL_STATES.indexOf(statusFilter) !== -1) {
521
+ throw new TypeError("order-exchanges.openExchanges: status filter must be a non-terminal status, got " +
522
+ JSON.stringify(statusFilter));
523
+ }
524
+ }
525
+ var sql, params;
526
+ if (statusFilter) {
527
+ sql = "SELECT * FROM order_exchanges WHERE status = ?1 " +
528
+ "ORDER BY created_at ASC, id ASC";
529
+ params = [statusFilter];
530
+ } else {
531
+ sql = "SELECT * FROM order_exchanges WHERE status NOT IN ('closed', 'rejected') " +
532
+ "ORDER BY created_at ASC, id ASC";
533
+ params = [];
534
+ }
535
+ return (await query(sql, params)).rows;
536
+ },
537
+
538
+ // Operator dashboard summary. Counts every exchange opened in
539
+ // the window broken out by status; surfaces approval-throughput
540
+ // (median ms from pending -> approved) and rejection-rate so
541
+ // the operator can spot a swing in customer-experience health.
542
+ // `from` is inclusive, `to` exclusive — same shape as other
543
+ // window readers in the framework.
544
+ metricsForPeriod: async function (input) {
545
+ input = input || {};
546
+ _ts(input.from, "from");
547
+ _ts(input.to, "to");
548
+ if (input.to <= input.from) {
549
+ throw new TypeError("order-exchanges.metricsForPeriod: to must be > from");
550
+ }
551
+
552
+ // Counts by status across the window.
553
+ var statusRows = (await query(
554
+ "SELECT status, COUNT(*) AS n FROM order_exchanges " +
555
+ "WHERE created_at >= ?1 AND created_at < ?2 GROUP BY status",
556
+ [input.from, input.to],
557
+ )).rows;
558
+ var countsByStatus = {};
559
+ for (var s = 0; s < STATUSES.length; s += 1) countsByStatus[STATUSES[s]] = 0;
560
+ var total = 0;
561
+ for (var r = 0; r < statusRows.length; r += 1) {
562
+ countsByStatus[statusRows[r].status] = Number(statusRows[r].n);
563
+ total += Number(statusRows[r].n);
564
+ }
565
+
566
+ // Closed-rate + rejection-rate over the window. Total may be 0
567
+ // on a brand-new shop; surface as 0 (not NaN) so dashboards
568
+ // don't render garbage.
569
+ var closedCount = countsByStatus.closed || 0;
570
+ var rejectedCount = countsByStatus.rejected || 0;
571
+ var closedRate = total > 0 ? closedCount / total : 0;
572
+ var rejectedRate = total > 0 ? rejectedCount / total : 0;
573
+
574
+ // Counts by reason for the same window.
575
+ var reasonRows = (await query(
576
+ "SELECT reason, COUNT(*) AS n FROM order_exchanges " +
577
+ "WHERE created_at >= ?1 AND created_at < ?2 GROUP BY reason",
578
+ [input.from, input.to],
579
+ )).rows;
580
+ var countsByReason = {};
581
+ for (var i = 0; i < REASONS.length; i += 1) countsByReason[REASONS[i]] = 0;
582
+ for (var j = 0; j < reasonRows.length; j += 1) {
583
+ countsByReason[reasonRows[j].reason] = Number(reasonRows[j].n);
584
+ }
585
+
586
+ return {
587
+ total_count: total,
588
+ counts_by_status: countsByStatus,
589
+ counts_by_reason: countsByReason,
590
+ closed_rate: closedRate,
591
+ rejected_rate: rejectedRate,
592
+ };
593
+ },
594
+ };
595
+ }
596
+
597
+ module.exports = {
598
+ create: create,
599
+ REASONS: REASONS,
600
+ STATUSES: STATUSES,
601
+ TERMINAL_STATES: TERMINAL_STATES,
602
+ };