@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.
- package/CHANGELOG.md +4 -0
- package/lib/affiliates.js +1025 -0
- package/lib/collections.js +916 -0
- package/lib/customer-segments.js +817 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +16 -0
- package/lib/mailing-audiences.js +855 -0
- package/lib/order-timeline.js +1073 -0
- package/lib/promo-banners.js +726 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/recently-viewed.js +511 -0
- package/lib/return-labels.js +477 -0
- package/lib/sales-reports.js +843 -0
- package/lib/search-synonyms.js +792 -0
- package/lib/shipping-labels.js +603 -0
- package/lib/stock-alerts.js +563 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
|
@@ -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
|
+
};
|