@blamejs/blamejs-shop 0.0.57 → 0.0.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1073 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.orderTimeline
4
+ * @title Order timeline — unified event feed across the post-checkout surface
5
+ *
6
+ * @intro
7
+ * A customer-service operator (or a customer on their order page)
8
+ * wants a single chronological feed of everything that happened
9
+ * to an order — not seven separate widgets keyed by source table.
10
+ * `orderTimeline` is the read-only aggregator: it queries the
11
+ * primitives that already own each event class and flattens the
12
+ * result into one `{ kind, occurred_at, title, body?, actor?,
13
+ * link? }` stream.
14
+ *
15
+ * Sources composed (each is optional — when the corresponding
16
+ * peer is NOT wired into the factory, that source is skipped
17
+ * silently and the remaining sources still surface):
18
+ *
19
+ * order — order_transitions rows (FSM events:
20
+ * mark_paid, start_fulfillment, mark_shipped,
21
+ * mark_delivered, cancel, refund).
22
+ * orderTracking — shipment_events rows + the shipments
23
+ * themselves (label-created, in-transit,
24
+ * out-for-delivery, delivered, exception,
25
+ * returned).
26
+ * orderNotes — order_notes rows (customer-service notes;
27
+ * `customer_visible` and `internal` shapes
28
+ * carry through with their visibility flag
29
+ * so customerVisibleFor() can filter).
30
+ * returns — return_authorizations rows (RMA requested,
31
+ * approved, received, refunded, rejected).
32
+ * fraudScreen — fraud_screenings rows (decision events
33
+ * with score + signals). Internal-only —
34
+ * never customer-visible.
35
+ * shippingLabels — shipping_labels rows (label purchased,
36
+ * voided, used). Operator-only.
37
+ * notifications — notifications rows joined by order_id
38
+ * embedded in payload_json. Customer-visible
39
+ * when the channel is in-app / email and
40
+ * the event_type is in the customer-safe
41
+ * allowlist.
42
+ * payment — accepted for API completeness; the payment
43
+ * primitive is a thin Stripe wrapper with
44
+ * no local ledger, so payment events surface
45
+ * via the order FSM's `mark_paid` transition
46
+ * and via fraudScreen's verdict. Wiring it
47
+ * is a no-op today; the slot stays for
48
+ * forward-compat with a future ledger.
49
+ *
50
+ * Surface:
51
+ *
52
+ * forOrder({ order_id, customer_visible_only? })
53
+ * — returns `[{ kind, occurred_at, title, body?, actor?, link? }]`
54
+ * newest-first. `customer_visible_only` (default false)
55
+ * restricts to the events a customer can see on their
56
+ * order page; with the flag off, every event surfaces for
57
+ * the operator console.
58
+ *
59
+ * summarize(order_id)
60
+ * — returns `{ status, first_event_at, last_event_at,
61
+ * event_count, milestones: { paid_at?, fulfilled_at?,
62
+ * shipped_at?, delivered_at?, refunded_at? } }`. Reads
63
+ * through the cache when fresh; recomputes from sources
64
+ * and refreshes the cache when not.
65
+ *
66
+ * customerVisibleFor(order_id, { locale })
67
+ * — same shape as `forOrder({ customer_visible_only: true })`
68
+ * with per-event titles overridden by the per-locale title
69
+ * map. Locale must be BCP-47 (`en`, `en-US`, `de-CH`, etc.);
70
+ * a locale with no override falls back to the canonical
71
+ * English title.
72
+ *
73
+ * compareOrders([order_id_a, order_id_b])
74
+ * — operator-debugging side-by-side view. Returns
75
+ * `{ a: { order_id, timeline }, b: { order_id, timeline } }`
76
+ * where each timeline is the operator-facing forOrder
77
+ * shape (no customer_visible_only).
78
+ *
79
+ * recentActivity({ limit, customer_id? })
80
+ * — flattened cross-order feed for the operator dashboard.
81
+ * Returns up to `limit` (1..100) events newest-first across
82
+ * every order touched in the window. When `customer_id` is
83
+ * provided, scopes to that customer's orders.
84
+ *
85
+ * Read-only:
86
+ * This primitive WRITES exactly one table: `order_timeline_cache`,
87
+ * and only as a memoization side-effect of summarize() /
88
+ * recentActivity(). Every event row lives in its source
89
+ * primitive's table — orderTimeline never inserts or mutates
90
+ * order_transitions / shipment_events / order_notes / etc.
91
+ *
92
+ * Composition (b.* primitives in use):
93
+ * - b.guardUuid — every order_id input passes through the
94
+ * strict profile so a hostile cursor can't
95
+ * smuggle SQL fragments through.
96
+ * - b.uuid.v7 — cache row primary key.
97
+ */
98
+
99
+ var bShop;
100
+ function _b() {
101
+ if (!bShop) bShop = require("./index");
102
+ return bShop.framework;
103
+ }
104
+
105
+ // ---- constants ----------------------------------------------------------
106
+
107
+ var MAX_RECENT_LIMIT = 100;
108
+ var DEFAULT_RECENT_LIMIT = 20;
109
+
110
+ // Cache freshness window. summarize() returns the cached row when
111
+ // computed_at is within this window AND no source has a newer
112
+ // event than last_event_at. The window is a hard upper bound so a
113
+ // pathological "no new events but the cache is stale" case still
114
+ // recomputes after a few minutes — gives the operator dashboard
115
+ // fresh totals even when the underlying primitives are quiet.
116
+ var CACHE_TTL_MS = 5 * 60 * 1000;
117
+
118
+ // BCP-47 shape: 2-3 alpha primary subtag, optional region/script
119
+ // subtags. Matches the shape gift-options uses for the same purpose.
120
+ var BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/;
121
+
122
+ // Notification event-types the customer is permitted to see on
123
+ // their own order page. Other notification kinds (operator-only
124
+ // admin alerts, fraud-team handoffs) are filtered out of the
125
+ // customer feed even when their payload references the order.
126
+ var CUSTOMER_NOTIFICATION_EVENTS = {
127
+ "order.confirmed": true,
128
+ "order.shipped": true,
129
+ "order.delivered": true,
130
+ "order.refunded": true,
131
+ "shipment.update": true,
132
+ "return.approved": true,
133
+ "return.received": true,
134
+ "return.refunded": true,
135
+ "payment.receipt": true,
136
+ };
137
+
138
+ // Order-FSM events → canonical English titles. Events not in this
139
+ // map fall through with `on_event` as the title.
140
+ var ORDER_EVENT_TITLES = {
141
+ "create": "Order placed",
142
+ "mark_paid": "Payment received",
143
+ "start_fulfillment": "Preparing for shipment",
144
+ "mark_shipped": "Order shipped",
145
+ "mark_delivered": "Order delivered",
146
+ "cancel": "Order cancelled",
147
+ "refund": "Order refunded",
148
+ };
149
+
150
+ // Shipment-event statuses → customer-readable titles.
151
+ var SHIPMENT_STATUS_TITLES = {
152
+ "pending": "Shipment created",
153
+ "label-created": "Shipping label printed",
154
+ "in-transit": "In transit",
155
+ "out-for-delivery": "Out for delivery",
156
+ "delivered": "Delivered",
157
+ "exception": "Delivery exception",
158
+ "returned": "Returned to sender",
159
+ };
160
+
161
+ // Order-FSM events that the customer sees on their order page.
162
+ // Internal mid-fulfillment events (`start_fulfillment`) stay
163
+ // operator-only — the customer sees "shipped" when the parcel
164
+ // actually ships, not when the warehouse picks it.
165
+ var CUSTOMER_VISIBLE_ORDER_EVENTS = {
166
+ "create": true,
167
+ "mark_paid": true,
168
+ "mark_shipped": true,
169
+ "mark_delivered": true,
170
+ "cancel": true,
171
+ "refund": true,
172
+ };
173
+
174
+ // Shipment-event statuses the customer sees.
175
+ var CUSTOMER_VISIBLE_SHIPMENT_STATUSES = {
176
+ "label-created": true,
177
+ "in-transit": true,
178
+ "out-for-delivery": true,
179
+ "delivered": true,
180
+ "exception": true,
181
+ "returned": true,
182
+ };
183
+
184
+ // Per-locale title overrides. Only a small set is shipped inline
185
+ // — the framework's i18n primitive is the place to register
186
+ // fuller catalogs; operators that need more locales can wrap this
187
+ // primitive and substitute their own table.
188
+ var LOCALE_TITLES = {
189
+ "en": {
190
+ "order.create": "Order placed",
191
+ "order.mark_paid": "Payment received",
192
+ "order.mark_shipped": "Order shipped",
193
+ "order.mark_delivered": "Order delivered",
194
+ "order.cancel": "Order cancelled",
195
+ "order.refund": "Order refunded",
196
+ "shipment.label-created": "Shipping label printed",
197
+ "shipment.in-transit": "In transit",
198
+ "shipment.out-for-delivery": "Out for delivery",
199
+ "shipment.delivered": "Delivered",
200
+ "shipment.exception": "Delivery exception",
201
+ "shipment.returned": "Returned to sender",
202
+ "return.requested": "Return requested",
203
+ "return.approved": "Return approved",
204
+ "return.received": "Return received",
205
+ "return.refunded": "Refund issued",
206
+ "return.rejected": "Return rejected",
207
+ "note": "Note from support",
208
+ },
209
+ "es": {
210
+ "order.create": "Pedido realizado",
211
+ "order.mark_paid": "Pago recibido",
212
+ "order.mark_shipped": "Pedido enviado",
213
+ "order.mark_delivered": "Pedido entregado",
214
+ "order.cancel": "Pedido cancelado",
215
+ "order.refund": "Pedido reembolsado",
216
+ "shipment.label-created": "Etiqueta de envio impresa",
217
+ "shipment.in-transit": "En transito",
218
+ "shipment.out-for-delivery": "En reparto",
219
+ "shipment.delivered": "Entregado",
220
+ "shipment.exception": "Excepcion de entrega",
221
+ "shipment.returned": "Devuelto al remitente",
222
+ "return.requested": "Devolucion solicitada",
223
+ "return.approved": "Devolucion aprobada",
224
+ "return.received": "Devolucion recibida",
225
+ "return.refunded": "Reembolso emitido",
226
+ "return.rejected": "Devolucion rechazada",
227
+ "note": "Nota de soporte",
228
+ },
229
+ "de": {
230
+ "order.create": "Bestellung aufgegeben",
231
+ "order.mark_paid": "Zahlung erhalten",
232
+ "order.mark_shipped": "Bestellung versandt",
233
+ "order.mark_delivered": "Bestellung zugestellt",
234
+ "order.cancel": "Bestellung storniert",
235
+ "order.refund": "Bestellung erstattet",
236
+ "shipment.label-created": "Versandetikett gedruckt",
237
+ "shipment.in-transit": "Unterwegs",
238
+ "shipment.out-for-delivery": "In Zustellung",
239
+ "shipment.delivered": "Zugestellt",
240
+ "shipment.exception": "Zustellungsproblem",
241
+ "shipment.returned": "An Absender retourniert",
242
+ "return.requested": "Ruecksendung angefragt",
243
+ "return.approved": "Ruecksendung genehmigt",
244
+ "return.received": "Ruecksendung erhalten",
245
+ "return.refunded": "Rueckerstattung ausgestellt",
246
+ "return.rejected": "Ruecksendung abgelehnt",
247
+ "note": "Hinweis vom Support",
248
+ },
249
+ };
250
+
251
+ // ---- validators ---------------------------------------------------------
252
+
253
+ function _uuid(s, label) {
254
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
255
+ catch (e) { throw new TypeError("orderTimeline: " + label + " — " + (e && e.message || "invalid UUID")); }
256
+ }
257
+
258
+ function _bool(v, label) {
259
+ if (v == null) return false;
260
+ if (typeof v !== "boolean") {
261
+ throw new TypeError("orderTimeline: " + label + " must be a boolean when provided");
262
+ }
263
+ return v;
264
+ }
265
+
266
+ function _limit(n, max, def) {
267
+ if (n == null) return def;
268
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
269
+ throw new TypeError("orderTimeline: limit must be an integer 1..." + max);
270
+ }
271
+ return n;
272
+ }
273
+
274
+ function _locale(s) {
275
+ if (typeof s !== "string" || !BCP47_RE.test(s)) {
276
+ throw new TypeError("orderTimeline: locale must be a BCP-47-shape string (e.g. 'en-US')");
277
+ }
278
+ return s;
279
+ }
280
+
281
+ function _now() { return Date.now(); }
282
+
283
+ // Resolve a locale string against the catalog. Tries the full tag
284
+ // ("en-US"), then the primary subtag ("en"), then falls back to
285
+ // "en". Keeps the surface tolerant of region tags the catalog
286
+ // doesn't explicitly enumerate.
287
+ function _resolveLocale(locale) {
288
+ var lc = locale.toLowerCase();
289
+ if (LOCALE_TITLES[lc]) return LOCALE_TITLES[lc];
290
+ var primary = lc.split("-")[0];
291
+ if (LOCALE_TITLES[primary]) return LOCALE_TITLES[primary];
292
+ return LOCALE_TITLES.en;
293
+ }
294
+
295
+ function _titleForKey(catalog, key, fallback) {
296
+ if (catalog && catalog[key]) return catalog[key];
297
+ return fallback;
298
+ }
299
+
300
+ // ---- factory ------------------------------------------------------------
301
+
302
+ function create(opts) {
303
+ opts = opts || {};
304
+ var query = opts.query;
305
+ if (!query) {
306
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
307
+ }
308
+ // Accept (but do not actively use) the payment peer — the
309
+ // payment primitive is a thin Stripe wrapper with no local
310
+ // ledger queryable by order_id. Payment milestones surface
311
+ // through the order FSM (mark_paid → order_transitions) and
312
+ // through fraudScreen verdicts. The slot stays for forward-
313
+ // compat when a payment-side event ledger lands.
314
+ var payment = opts.payment || null;
315
+ if (payment) { /* reserved — see header */ }
316
+ var orderPrim = opts.order || null;
317
+ var orderTracking = opts.orderTracking || null;
318
+ var orderNotes = opts.orderNotes || null;
319
+ var returnsPrim = opts.returns || null;
320
+ var fraudScreen = opts.fraudScreen || null;
321
+ var shippingLabels = opts.shippingLabels || null;
322
+ var notifications = opts.notifications || null;
323
+
324
+ // ---- per-source collectors --------------------------------------------
325
+ //
326
+ // Each collector returns an array of `{ kind, occurred_at, title,
327
+ // body?, actor?, link?, customer_visible }` records. Missing peers
328
+ // collapse to an empty list so the aggregator stays the same shape
329
+ // regardless of wiring. Source ownership: each collector queries
330
+ // its own source primitive's table — orderTimeline never invents
331
+ // SQL against a table whose owning primitive isn't wired in.
332
+
333
+ async function _collectOrderEvents(orderId) {
334
+ if (!orderPrim) return [];
335
+ var rows = (await query(
336
+ "SELECT id, from_state, to_state, on_event, reason, metadata_json, occurred_at " +
337
+ "FROM order_transitions WHERE order_id = ?1 ORDER BY occurred_at ASC",
338
+ [orderId],
339
+ )).rows;
340
+ var out = [];
341
+ for (var i = 0; i < rows.length; i += 1) {
342
+ var r = rows[i];
343
+ var title = ORDER_EVENT_TITLES[r.on_event] || r.on_event;
344
+ var meta;
345
+ try { meta = JSON.parse(r.metadata_json || "{}"); }
346
+ catch (_e) { meta = {}; } // drop-silent — bad JSON falls through to empty
347
+ var body = r.reason
348
+ ? r.reason
349
+ : (r.from_state + " → " + r.to_state);
350
+ out.push({
351
+ kind: "order." + r.on_event,
352
+ occurred_at: Number(r.occurred_at),
353
+ title: title,
354
+ body: body,
355
+ actor: "system",
356
+ link: null,
357
+ customer_visible: CUSTOMER_VISIBLE_ORDER_EVENTS[r.on_event] === true,
358
+ _localeKey: "order." + r.on_event,
359
+ _metadata: meta,
360
+ });
361
+ }
362
+ return out;
363
+ }
364
+
365
+ async function _collectShipmentEvents(orderId) {
366
+ if (!orderTracking) return [];
367
+ // Pull the shipments owned by this order, then their events
368
+ // in one pass. Two trips total — keeps the per-row composition
369
+ // simple and matches orderTracking's own listForOrder shape.
370
+ var shipments = await orderTracking.listForOrder(orderId);
371
+ var out = [];
372
+ for (var s = 0; s < shipments.length; s += 1) {
373
+ var sh = shipments[s];
374
+ var evRows = (await query(
375
+ "SELECT id, status, location, detail, occurred_at " +
376
+ "FROM shipment_events WHERE shipment_id = ?1 ORDER BY occurred_at ASC",
377
+ [sh.id],
378
+ )).rows;
379
+ for (var i = 0; i < evRows.length; i += 1) {
380
+ var e = evRows[i];
381
+ var title = SHIPMENT_STATUS_TITLES[e.status] || e.status;
382
+ var parts = [];
383
+ if (e.location) parts.push(e.location);
384
+ if (e.detail) parts.push(e.detail);
385
+ out.push({
386
+ kind: "shipment." + e.status,
387
+ occurred_at: Number(e.occurred_at),
388
+ title: title,
389
+ body: parts.length ? parts.join(" — ") : null,
390
+ actor: "carrier",
391
+ link: sh.tracking_url || null,
392
+ customer_visible: CUSTOMER_VISIBLE_SHIPMENT_STATUSES[e.status] === true,
393
+ _localeKey: "shipment." + e.status,
394
+ _metadata: {
395
+ shipment_id: sh.id,
396
+ carrier: sh.carrier,
397
+ tracking_number: sh.tracking_number || null,
398
+ },
399
+ });
400
+ }
401
+ }
402
+ return out;
403
+ }
404
+
405
+ async function _collectNotes(orderId) {
406
+ if (!orderNotes) return [];
407
+ var rows = (await query(
408
+ "SELECT id, parent_note_id, author, author_id, visibility, body, " +
409
+ "created_at FROM order_notes WHERE order_id = ?1 ORDER BY created_at ASC",
410
+ [orderId],
411
+ )).rows;
412
+ var out = [];
413
+ for (var i = 0; i < rows.length; i += 1) {
414
+ var n = rows[i];
415
+ out.push({
416
+ kind: "note." + n.visibility,
417
+ occurred_at: Number(n.created_at),
418
+ title: n.visibility === "customer_visible"
419
+ ? "Note from support"
420
+ : "Internal note",
421
+ body: n.body,
422
+ actor: n.author,
423
+ link: null,
424
+ customer_visible: n.visibility === "customer_visible",
425
+ _localeKey: "note",
426
+ _metadata: {
427
+ note_id: n.id,
428
+ parent_note_id: n.parent_note_id || null,
429
+ visibility: n.visibility,
430
+ },
431
+ });
432
+ }
433
+ return out;
434
+ }
435
+
436
+ async function _collectReturns(orderId) {
437
+ if (!returnsPrim) return [];
438
+ var rows = (await query(
439
+ "SELECT id, rma_code, status, reason, customer_notes, operator_notes, " +
440
+ "approved_at, received_at, refunded_at, rejected_at, rejected_reason, " +
441
+ "created_at FROM return_authorizations WHERE order_id = ?1 " +
442
+ "ORDER BY created_at ASC",
443
+ [orderId],
444
+ )).rows;
445
+ var out = [];
446
+ for (var i = 0; i < rows.length; i += 1) {
447
+ var r = rows[i];
448
+ // Every RMA contributes a 'requested' event at created_at,
449
+ // plus one per terminal/intermediate timestamp present.
450
+ out.push({
451
+ kind: "return.requested",
452
+ occurred_at: Number(r.created_at),
453
+ title: "Return requested",
454
+ body: r.rma_code + " — " + r.reason,
455
+ actor: "customer",
456
+ link: null,
457
+ customer_visible: true,
458
+ _localeKey: "return.requested",
459
+ _metadata: { rma_id: r.id, rma_code: r.rma_code, status: r.status },
460
+ });
461
+ if (r.approved_at) {
462
+ out.push({
463
+ kind: "return.approved",
464
+ occurred_at: Number(r.approved_at),
465
+ title: "Return approved",
466
+ body: r.rma_code,
467
+ actor: "operator",
468
+ link: null,
469
+ customer_visible: true,
470
+ _localeKey: "return.approved",
471
+ _metadata: { rma_id: r.id, rma_code: r.rma_code },
472
+ });
473
+ }
474
+ if (r.received_at) {
475
+ out.push({
476
+ kind: "return.received",
477
+ occurred_at: Number(r.received_at),
478
+ title: "Return received",
479
+ body: r.rma_code,
480
+ actor: "operator",
481
+ link: null,
482
+ customer_visible: true,
483
+ _localeKey: "return.received",
484
+ _metadata: { rma_id: r.id, rma_code: r.rma_code },
485
+ });
486
+ }
487
+ if (r.refunded_at) {
488
+ out.push({
489
+ kind: "return.refunded",
490
+ occurred_at: Number(r.refunded_at),
491
+ title: "Refund issued",
492
+ body: r.rma_code,
493
+ actor: "operator",
494
+ link: null,
495
+ customer_visible: true,
496
+ _localeKey: "return.refunded",
497
+ _metadata: { rma_id: r.id, rma_code: r.rma_code },
498
+ });
499
+ }
500
+ if (r.rejected_at) {
501
+ out.push({
502
+ kind: "return.rejected",
503
+ occurred_at: Number(r.rejected_at),
504
+ title: "Return rejected",
505
+ body: r.rejected_reason || r.rma_code,
506
+ actor: "operator",
507
+ link: null,
508
+ customer_visible: true,
509
+ _localeKey: "return.rejected",
510
+ _metadata: { rma_id: r.id, rma_code: r.rma_code },
511
+ });
512
+ }
513
+ }
514
+ return out;
515
+ }
516
+
517
+ async function _collectFraudScreenings(orderId) {
518
+ if (!fraudScreen) return [];
519
+ var rows = (await query(
520
+ "SELECT id, score, decision, signals_json, actual_outcome, occurred_at " +
521
+ "FROM fraud_screenings WHERE order_id = ?1 ORDER BY occurred_at ASC",
522
+ [orderId],
523
+ )).rows;
524
+ var out = [];
525
+ for (var i = 0; i < rows.length; i += 1) {
526
+ var r = rows[i];
527
+ out.push({
528
+ kind: "fraud.decision",
529
+ occurred_at: Number(r.occurred_at),
530
+ title: "Fraud screen: " + r.decision,
531
+ body: "Score " + r.score + (r.actual_outcome ? " — outcome " + r.actual_outcome : ""),
532
+ actor: "system",
533
+ link: null,
534
+ customer_visible: false,
535
+ _localeKey: "fraud.decision",
536
+ _metadata: {
537
+ screening_id: r.id,
538
+ score: r.score,
539
+ decision: r.decision,
540
+ actual_outcome: r.actual_outcome || null,
541
+ },
542
+ });
543
+ }
544
+ return out;
545
+ }
546
+
547
+ async function _collectShippingLabels(orderId) {
548
+ if (!shippingLabels) return [];
549
+ // Labels are owned per-shipment; join through shipments to
550
+ // reach the labels for this order. Query the table directly
551
+ // because the shippingLabels primitive surface is still pending
552
+ // — the table exists at migration 0051 and is the canonical
553
+ // source. When the primitive lands, this collector switches to
554
+ // calling its listForOrder(orderId) instead.
555
+ var rows = (await query(
556
+ "SELECT sl.id, sl.carrier, sl.tracking_number, sl.service_level, " +
557
+ "sl.status, sl.cost_minor, sl.currency, sl.purchased_via, " +
558
+ "sl.created_at, sl.purchased_at, sl.voided_at, sl.used_at " +
559
+ "FROM shipping_labels sl " +
560
+ "JOIN shipments s ON s.id = sl.shipment_id " +
561
+ "WHERE s.order_id = ?1 " +
562
+ "ORDER BY sl.created_at ASC",
563
+ [orderId],
564
+ )).rows;
565
+ var out = [];
566
+ for (var i = 0; i < rows.length; i += 1) {
567
+ var l = rows[i];
568
+ out.push({
569
+ kind: "label.requested",
570
+ occurred_at: Number(l.created_at),
571
+ title: "Shipping label requested",
572
+ body: l.carrier + " · " + l.service_level,
573
+ actor: "operator",
574
+ link: null,
575
+ customer_visible: false,
576
+ _localeKey: "label.requested",
577
+ _metadata: {
578
+ label_id: l.id,
579
+ carrier: l.carrier,
580
+ purchased_via: l.purchased_via || null,
581
+ },
582
+ });
583
+ if (l.purchased_at) {
584
+ out.push({
585
+ kind: "label.purchased",
586
+ occurred_at: Number(l.purchased_at),
587
+ title: "Shipping label purchased",
588
+ body: l.carrier + (l.cost_minor != null ? " · " + l.cost_minor + " " + (l.currency || "") : ""),
589
+ actor: "operator",
590
+ link: null,
591
+ customer_visible: false,
592
+ _localeKey: "label.purchased",
593
+ _metadata: { label_id: l.id, carrier: l.carrier },
594
+ });
595
+ }
596
+ if (l.voided_at) {
597
+ out.push({
598
+ kind: "label.voided",
599
+ occurred_at: Number(l.voided_at),
600
+ title: "Shipping label voided",
601
+ body: l.carrier,
602
+ actor: "operator",
603
+ link: null,
604
+ customer_visible: false,
605
+ _localeKey: "label.voided",
606
+ _metadata: { label_id: l.id, carrier: l.carrier },
607
+ });
608
+ }
609
+ if (l.used_at) {
610
+ out.push({
611
+ kind: "label.used",
612
+ occurred_at: Number(l.used_at),
613
+ title: "Shipping label handed to carrier",
614
+ body: l.carrier,
615
+ actor: "operator",
616
+ link: null,
617
+ customer_visible: false,
618
+ _localeKey: "label.used",
619
+ _metadata: { label_id: l.id, carrier: l.carrier },
620
+ });
621
+ }
622
+ }
623
+ return out;
624
+ }
625
+
626
+ async function _collectNotifications(orderId) {
627
+ if (!notifications) return [];
628
+ // notifications has no order_id column — the link is via
629
+ // payload_json. Cheap LIKE filter on the JSON blob: the
630
+ // dispatcher writes `"order_id":"<uuid>"` into payload when
631
+ // the notification relates to an order. Best-effort: a
632
+ // notification payload that uses a different key shape won't
633
+ // surface here, which the operator handles by wrapping their
634
+ // dispatcher to also call this primitive's add-side surface
635
+ // when that exists.
636
+ var needle = '"order_id":"' + orderId + '"';
637
+ var rows = (await query(
638
+ "SELECT id, channel, event_type, title, body, status, scheduled_at, " +
639
+ "sent_at, read_at, created_at FROM notifications " +
640
+ "WHERE payload_json LIKE ?1 ORDER BY created_at ASC",
641
+ ["%" + needle + "%"],
642
+ )).rows;
643
+ var out = [];
644
+ for (var i = 0; i < rows.length; i += 1) {
645
+ var n = rows[i];
646
+ var when = Number(n.sent_at || n.created_at);
647
+ var visible = CUSTOMER_NOTIFICATION_EVENTS[n.event_type] === true
648
+ && (n.channel === "in-app" || n.channel === "email");
649
+ out.push({
650
+ kind: "notification." + n.event_type,
651
+ occurred_at: when,
652
+ title: n.title || n.event_type,
653
+ body: n.body || null,
654
+ actor: "system",
655
+ link: null,
656
+ customer_visible: visible,
657
+ _localeKey: "notification." + n.event_type,
658
+ _metadata: {
659
+ notification_id: n.id,
660
+ channel: n.channel,
661
+ status: n.status,
662
+ },
663
+ });
664
+ }
665
+ return out;
666
+ }
667
+
668
+ // ---- aggregation ------------------------------------------------------
669
+
670
+ async function _collectAll(orderId) {
671
+ var batches = await Promise.all([
672
+ _collectOrderEvents(orderId),
673
+ _collectShipmentEvents(orderId),
674
+ _collectNotes(orderId),
675
+ _collectReturns(orderId),
676
+ _collectFraudScreenings(orderId),
677
+ _collectShippingLabels(orderId),
678
+ _collectNotifications(orderId),
679
+ ]);
680
+ var flat = [];
681
+ for (var b = 0; b < batches.length; b += 1) {
682
+ for (var i = 0; i < batches[b].length; i += 1) {
683
+ flat.push(batches[b][i]);
684
+ }
685
+ }
686
+ return flat;
687
+ }
688
+
689
+ function _sortNewestFirst(events) {
690
+ events.sort(function (a, b) {
691
+ if (b.occurred_at !== a.occurred_at) return b.occurred_at - a.occurred_at;
692
+ // Tie-break on kind so the order is deterministic across
693
+ // platforms. The choice of kind ordering is arbitrary but
694
+ // stable; tests that compare consecutive same-ms events can
695
+ // rely on it.
696
+ if (a.kind < b.kind) return 1;
697
+ if (a.kind > b.kind) return -1;
698
+ return 0;
699
+ });
700
+ return events;
701
+ }
702
+
703
+ function _stripInternalFields(events) {
704
+ var out = [];
705
+ for (var i = 0; i < events.length; i += 1) {
706
+ var e = events[i];
707
+ out.push({
708
+ kind: e.kind,
709
+ occurred_at: e.occurred_at,
710
+ title: e.title,
711
+ body: e.body == null ? null : e.body,
712
+ actor: e.actor || null,
713
+ link: e.link || null,
714
+ });
715
+ }
716
+ return out;
717
+ }
718
+
719
+ function _extractMilestones(events) {
720
+ var m = {};
721
+ for (var i = 0; i < events.length; i += 1) {
722
+ var e = events[i];
723
+ if (e.kind === "order.mark_paid" && m.paid_at == null) m.paid_at = e.occurred_at;
724
+ if (e.kind === "order.start_fulfillment" && m.fulfilled_at == null) m.fulfilled_at = e.occurred_at;
725
+ if (e.kind === "order.mark_shipped" && m.shipped_at == null) m.shipped_at = e.occurred_at;
726
+ if (e.kind === "order.mark_delivered" && m.delivered_at == null) m.delivered_at = e.occurred_at;
727
+ if (e.kind === "order.refund" && m.refunded_at == null) m.refunded_at = e.occurred_at;
728
+ }
729
+ return m;
730
+ }
731
+
732
+ async function _readOrderStatus(orderId) {
733
+ if (!orderPrim) return null;
734
+ var r = await query("SELECT status FROM orders WHERE id = ?1", [orderId]);
735
+ return r.rows.length ? r.rows[0].status : null;
736
+ }
737
+
738
+ async function _cacheGet(orderId) {
739
+ var r = await query(
740
+ "SELECT order_id, event_count, last_event_at, milestones_json, computed_at " +
741
+ "FROM order_timeline_cache WHERE order_id = ?1",
742
+ [orderId],
743
+ );
744
+ return r.rows[0] || null;
745
+ }
746
+
747
+ async function _cachePut(orderId, summary) {
748
+ var ts = _now();
749
+ // INSERT-or-REPLACE on the PK keeps the row count stable. SQLite's
750
+ // INSERT OR REPLACE is the cross-dialect-compatible upsert shape
751
+ // — same approach sales-report-cache uses (DELETE + INSERT) for
752
+ // engines that lack ON CONFLICT.
753
+ var blob = Object.assign({}, summary.milestones || {});
754
+ // _source_row_count is the freshness-bookkeeping field —
755
+ // underscored so the public milestones object returned by
756
+ // summarize() never carries it.
757
+ if (typeof summary.source_row_count === "number") {
758
+ blob._source_row_count = summary.source_row_count;
759
+ }
760
+ await query("DELETE FROM order_timeline_cache WHERE order_id = ?1", [orderId]);
761
+ await query(
762
+ "INSERT INTO order_timeline_cache " +
763
+ "(order_id, event_count, last_event_at, milestones_json, computed_at) " +
764
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
765
+ [
766
+ orderId,
767
+ summary.event_count,
768
+ summary.last_event_at || 0,
769
+ JSON.stringify(blob),
770
+ ts,
771
+ ],
772
+ );
773
+ }
774
+
775
+ // The cache is fresh when computed_at is within CACHE_TTL_MS AND
776
+ // no source has a newer event than last_event_at AND the per-
777
+ // source row counts match what the cache row recorded. The "no
778
+ // newer event" check alone isn't sufficient — two events landing
779
+ // in the same epoch-ms (common on a fast in-memory test runner or
780
+ // when an automation fans events out in a tight loop) would slip
781
+ // past a max-only comparison. The per-source row count closes
782
+ // that gap: if a source has more rows than the cache last saw,
783
+ // the cache is stale even when the timestamps tie.
784
+ //
785
+ // The recorded source-row-count lives in the cache's
786
+ // milestones_json blob under the `_source_row_count` key so the
787
+ // schema migration didn't need a second column dedicated to
788
+ // freshness bookkeeping.
789
+ async function _cacheIsFresh(cacheRow, nowTs) {
790
+ if (!cacheRow) return false;
791
+ if (nowTs - Number(cacheRow.computed_at) > CACHE_TTL_MS) return false;
792
+ var orderId = cacheRow.order_id;
793
+ var stats = await _sourceStats(orderId);
794
+ if (stats.latest > Number(cacheRow.last_event_at)) return false;
795
+ var recordedCount = null;
796
+ try {
797
+ var blob = JSON.parse(cacheRow.milestones_json || "{}");
798
+ if (typeof blob._source_row_count === "number") recordedCount = blob._source_row_count;
799
+ } catch (_e) { recordedCount = null; } // drop-silent — unparseable cache forces a refresh
800
+ if (recordedCount == null) return false;
801
+ if (stats.totalCount !== recordedCount) return false;
802
+ return true;
803
+ }
804
+
805
+ // Roll up the per-source freshness stats. Returns `{ latest,
806
+ // totalCount }` — the maximum timestamp across every wired
807
+ // source and the total row count those sources contribute to
808
+ // this order. Used by both the cache freshness check and the
809
+ // cache-store path so the row count written into the cache row
810
+ // matches the count used to invalidate it.
811
+ async function _sourceStats(orderId) {
812
+ var checks = [];
813
+ if (orderPrim) {
814
+ checks.push(query(
815
+ "SELECT MAX(occurred_at) AS m, COUNT(*) AS n FROM order_transitions WHERE order_id = ?1",
816
+ [orderId],
817
+ ));
818
+ }
819
+ if (orderTracking) {
820
+ checks.push(query(
821
+ "SELECT MAX(se.occurred_at) AS m, COUNT(*) AS n FROM shipment_events se " +
822
+ "JOIN shipments s ON s.id = se.shipment_id WHERE s.order_id = ?1",
823
+ [orderId],
824
+ ));
825
+ }
826
+ if (orderNotes) {
827
+ checks.push(query(
828
+ "SELECT MAX(created_at) AS m, COUNT(*) AS n FROM order_notes WHERE order_id = ?1",
829
+ [orderId],
830
+ ));
831
+ }
832
+ if (returnsPrim) {
833
+ checks.push(query(
834
+ "SELECT " +
835
+ "(SELECT MAX(created_at) FROM return_authorizations WHERE order_id = ?1) AS c1, " +
836
+ "(SELECT MAX(approved_at) FROM return_authorizations WHERE order_id = ?1) AS c2, " +
837
+ "(SELECT MAX(received_at) FROM return_authorizations WHERE order_id = ?1) AS c3, " +
838
+ "(SELECT MAX(refunded_at) FROM return_authorizations WHERE order_id = ?1) AS c4, " +
839
+ "(SELECT MAX(rejected_at) FROM return_authorizations WHERE order_id = ?1) AS c5, " +
840
+ "(SELECT COUNT(*) FROM return_authorizations WHERE order_id = ?1) AS n",
841
+ [orderId],
842
+ ));
843
+ }
844
+ if (fraudScreen) {
845
+ checks.push(query(
846
+ "SELECT MAX(occurred_at) AS m, COUNT(*) AS n FROM fraud_screenings WHERE order_id = ?1",
847
+ [orderId],
848
+ ));
849
+ }
850
+ if (shippingLabels) {
851
+ checks.push(query(
852
+ "SELECT MAX(sl.purchased_at) AS m, COUNT(*) AS n FROM shipping_labels sl " +
853
+ "JOIN shipments s ON s.id = sl.shipment_id WHERE s.order_id = ?1",
854
+ [orderId],
855
+ ));
856
+ }
857
+ if (notifications) {
858
+ checks.push(query(
859
+ "SELECT MAX(created_at) AS m, COUNT(*) AS n FROM notifications " +
860
+ "WHERE payload_json LIKE '%\"order_id\":\"' || ?1 || '\"%'",
861
+ [orderId],
862
+ ));
863
+ }
864
+ var results = await Promise.all(checks);
865
+ var latest = 0;
866
+ var totalCount = 0;
867
+ for (var i = 0; i < results.length; i += 1) {
868
+ var rows = results[i].rows;
869
+ if (!rows.length) continue;
870
+ var row = rows[0];
871
+ var keys = Object.keys(row);
872
+ for (var k = 0; k < keys.length; k += 1) {
873
+ var key = keys[k];
874
+ var raw = row[key];
875
+ if (raw == null) continue;
876
+ if (key === "n") {
877
+ totalCount += Number(raw) || 0;
878
+ continue;
879
+ }
880
+ var v = Number(raw);
881
+ if (Number.isFinite(v) && v > latest) latest = v;
882
+ }
883
+ }
884
+ return { latest: latest, totalCount: totalCount };
885
+ }
886
+
887
+ // ---- public surface ---------------------------------------------------
888
+
889
+ return {
890
+ CACHE_TTL_MS: CACHE_TTL_MS,
891
+ MAX_RECENT_LIMIT: MAX_RECENT_LIMIT,
892
+ DEFAULT_RECENT_LIMIT: DEFAULT_RECENT_LIMIT,
893
+
894
+ forOrder: async function (input) {
895
+ if (!input || typeof input !== "object") {
896
+ throw new TypeError("orderTimeline.forOrder: input object required");
897
+ }
898
+ var orderId = _uuid(input.order_id, "order_id");
899
+ var visibleOnly = _bool(input.customer_visible_only, "customer_visible_only");
900
+
901
+ var events = await _collectAll(orderId);
902
+ if (visibleOnly) {
903
+ events = events.filter(function (e) { return e.customer_visible === true; });
904
+ }
905
+ _sortNewestFirst(events);
906
+ return _stripInternalFields(events);
907
+ },
908
+
909
+ summarize: async function (orderId) {
910
+ orderId = _uuid(orderId, "order_id");
911
+ var nowTs = _now();
912
+ var cached = await _cacheGet(orderId);
913
+ if (await _cacheIsFresh(cached, nowTs)) {
914
+ var milestones = {};
915
+ try { milestones = JSON.parse(cached.milestones_json || "{}"); }
916
+ catch (_e) { milestones = {}; } // drop-silent — bad cache JSON falls through to fresh fields
917
+ // Strip the underscored bookkeeping key so the public
918
+ // milestones object stays clean.
919
+ if (milestones && Object.prototype.hasOwnProperty.call(milestones, "_source_row_count")) {
920
+ delete milestones._source_row_count;
921
+ }
922
+ return {
923
+ status: await _readOrderStatus(orderId),
924
+ first_event_at: null, // not stored in cache; recompute by walking sources
925
+ last_event_at: Number(cached.last_event_at) || null,
926
+ event_count: Number(cached.event_count) || 0,
927
+ milestones: milestones,
928
+ cache_hit: true,
929
+ };
930
+ }
931
+ var events = await _collectAll(orderId);
932
+ _sortNewestFirst(events);
933
+ var first = events.length ? events[events.length - 1].occurred_at : null;
934
+ var last = events.length ? events[0].occurred_at : null;
935
+ var miles = _extractMilestones(events);
936
+ var stats = await _sourceStats(orderId);
937
+ var summary = {
938
+ status: await _readOrderStatus(orderId),
939
+ first_event_at: first,
940
+ last_event_at: last,
941
+ event_count: events.length,
942
+ milestones: miles,
943
+ cache_hit: false,
944
+ };
945
+ await _cachePut(orderId, {
946
+ event_count: events.length,
947
+ last_event_at: last || 0,
948
+ milestones: miles,
949
+ source_row_count: stats.totalCount,
950
+ });
951
+ return summary;
952
+ },
953
+
954
+ customerVisibleFor: async function (orderId, lopts) {
955
+ orderId = _uuid(orderId, "order_id");
956
+ if (!lopts || typeof lopts !== "object") {
957
+ throw new TypeError("orderTimeline.customerVisibleFor: options object required");
958
+ }
959
+ var locale = _locale(lopts.locale);
960
+ var catalog = _resolveLocale(locale);
961
+
962
+ var events = await _collectAll(orderId);
963
+ events = events.filter(function (e) { return e.customer_visible === true; });
964
+ _sortNewestFirst(events);
965
+ // Per-locale title swap before stripping internal fields. The
966
+ // locale catalog is keyed by `_localeKey`; absent entries fall
967
+ // back to the canonical English title already on the event.
968
+ for (var i = 0; i < events.length; i += 1) {
969
+ events[i].title = _titleForKey(catalog, events[i]._localeKey, events[i].title);
970
+ }
971
+ var stripped = _stripInternalFields(events);
972
+ return { locale: locale, events: stripped };
973
+ },
974
+
975
+ compareOrders: async function (orderIds) {
976
+ if (!Array.isArray(orderIds) || orderIds.length !== 2) {
977
+ throw new TypeError("orderTimeline.compareOrders: orderIds must be a 2-element array");
978
+ }
979
+ var a = _uuid(orderIds[0], "orderIds[0]");
980
+ var b = _uuid(orderIds[1], "orderIds[1]");
981
+ var batches = await Promise.all([
982
+ _collectAll(a),
983
+ _collectAll(b),
984
+ ]);
985
+ _sortNewestFirst(batches[0]);
986
+ _sortNewestFirst(batches[1]);
987
+ return {
988
+ a: { order_id: a, timeline: _stripInternalFields(batches[0]) },
989
+ b: { order_id: b, timeline: _stripInternalFields(batches[1]) },
990
+ };
991
+ },
992
+
993
+ recentActivity: async function (listOpts) {
994
+ if (!listOpts || typeof listOpts !== "object") {
995
+ throw new TypeError("orderTimeline.recentActivity: options object required");
996
+ }
997
+ var limit = _limit(listOpts.limit, MAX_RECENT_LIMIT, DEFAULT_RECENT_LIMIT);
998
+ var customerId = null;
999
+ if (listOpts.customer_id != null) {
1000
+ customerId = _uuid(listOpts.customer_id, "customer_id");
1001
+ }
1002
+
1003
+ // Scope: pull the most recently touched orders, then collect
1004
+ // their timelines and merge. The cache's last_event_at index
1005
+ // drives the scope query — cheap, even when the orders table
1006
+ // is large.
1007
+ var scopeSql, scopeParams;
1008
+ if (customerId) {
1009
+ scopeSql = "SELECT o.id, c.last_event_at FROM orders o " +
1010
+ "LEFT JOIN order_timeline_cache c ON c.order_id = o.id " +
1011
+ "WHERE o.customer_id = ?1 " +
1012
+ "ORDER BY COALESCE(c.last_event_at, o.updated_at) DESC " +
1013
+ "LIMIT ?2";
1014
+ scopeParams = [customerId, limit];
1015
+ } else {
1016
+ scopeSql = "SELECT o.id, c.last_event_at FROM orders o " +
1017
+ "LEFT JOIN order_timeline_cache c ON c.order_id = o.id " +
1018
+ "ORDER BY COALESCE(c.last_event_at, o.updated_at) DESC " +
1019
+ "LIMIT ?1";
1020
+ scopeParams = [limit];
1021
+ }
1022
+ var scopeRows = (await query(scopeSql, scopeParams)).rows;
1023
+
1024
+ var merged = [];
1025
+ for (var i = 0; i < scopeRows.length; i += 1) {
1026
+ var oid = scopeRows[i].id;
1027
+ var ev = await _collectAll(oid);
1028
+ for (var j = 0; j < ev.length; j += 1) {
1029
+ var rec = ev[j];
1030
+ merged.push({
1031
+ order_id: oid,
1032
+ kind: rec.kind,
1033
+ occurred_at: rec.occurred_at,
1034
+ title: rec.title,
1035
+ body: rec.body == null ? null : rec.body,
1036
+ actor: rec.actor || null,
1037
+ link: rec.link || null,
1038
+ });
1039
+ }
1040
+ }
1041
+ merged.sort(function (x, y) {
1042
+ if (y.occurred_at !== x.occurred_at) return y.occurred_at - x.occurred_at;
1043
+ if (x.kind < y.kind) return 1;
1044
+ if (x.kind > y.kind) return -1;
1045
+ return 0;
1046
+ });
1047
+ if (merged.length > limit) merged.length = limit;
1048
+ return merged;
1049
+ },
1050
+
1051
+ // Operator-facing cache sweep. Removes rows whose `computed_at`
1052
+ // is older than the TTL — useful as a daily cron when the
1053
+ // dashboard hasn't refreshed an order in days.
1054
+ purgeStaleCache: async function (olderThanMs) {
1055
+ var bound = Number.isInteger(olderThanMs) && olderThanMs > 0
1056
+ ? olderThanMs
1057
+ : CACHE_TTL_MS * 12;
1058
+ var cutoff = _now() - bound;
1059
+ var r = await query(
1060
+ "DELETE FROM order_timeline_cache WHERE computed_at < ?1",
1061
+ [cutoff],
1062
+ );
1063
+ return { purged: Number(r.rowCount || 0) };
1064
+ },
1065
+ };
1066
+ }
1067
+
1068
+ module.exports = {
1069
+ create: create,
1070
+ CACHE_TTL_MS: CACHE_TTL_MS,
1071
+ MAX_RECENT_LIMIT: MAX_RECENT_LIMIT,
1072
+ DEFAULT_RECENT_LIMIT: DEFAULT_RECENT_LIMIT,
1073
+ };