@blamejs/blamejs-shop 0.0.66 → 0.0.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/click-and-collect.js +711 -0
  5. package/lib/clickstream.js +713 -0
  6. package/lib/customer-activity.js +862 -0
  7. package/lib/customer-notes.js +712 -0
  8. package/lib/customer-risk-profile.js +593 -0
  9. package/lib/customer-surveys.js +1012 -0
  10. package/lib/damage-photos.js +473 -0
  11. package/lib/dropship-forwarding.js +645 -0
  12. package/lib/email-templates.js +817 -0
  13. package/lib/index.js +35 -0
  14. package/lib/inventory-allocations.js +559 -0
  15. package/lib/inventory-writeoffs.js +636 -0
  16. package/lib/knowledge-base.js +1104 -0
  17. package/lib/locale-router.js +1077 -0
  18. package/lib/operator-roles.js +768 -0
  19. package/lib/order-escalation.js +951 -0
  20. package/lib/order-ratings.js +495 -0
  21. package/lib/order-tags.js +944 -0
  22. package/lib/packing-slips.js +810 -0
  23. package/lib/pixel-events.js +995 -0
  24. package/lib/print-queue.js +681 -0
  25. package/lib/product-qa.js +749 -0
  26. package/lib/promo-bundles.js +835 -0
  27. package/lib/push-notifications.js +937 -0
  28. package/lib/refund-automation.js +853 -0
  29. package/lib/reorder-reminders.js +798 -0
  30. package/lib/robots-config.js +753 -0
  31. package/lib/seller-signup.js +1052 -0
  32. package/lib/sitemap-generator.js +717 -0
  33. package/lib/subscription-gifts.js +710 -0
  34. package/lib/tax-cert-renewals.js +632 -0
  35. package/lib/tier-benefits.js +776 -0
  36. package/lib/vendor/MANIFEST.json +2 -2
  37. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  38. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  39. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  40. package/lib/vendor/blamejs/package.json +1 -1
  41. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  42. package/lib/wishlist-alerts.js +842 -0
  43. package/lib/wishlist-sharing.js +718 -0
  44. package/package.json +1 -1
@@ -0,0 +1,862 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.customerActivity
4
+ * @title Customer activity — chronological per-customer event timeline
5
+ *
6
+ * @intro
7
+ * A customer-service operator on the customer-detail page wants a
8
+ * single chronological feed of everything that customer has done
9
+ * recently — orders placed and progressed, items wishlisted,
10
+ * loyalty points earned or redeemed, support tickets opened and
11
+ * resolved, reviews submitted — not five separate widgets each
12
+ * keyed by a different source table. `customerActivity` is the
13
+ * read-only aggregator: it queries the primitives that already
14
+ * own each event class and flattens the result into one
15
+ * `{ kind, occurred_at, title, body, link?, actor? }` stream.
16
+ *
17
+ * Sources composed (each is optional — when the corresponding peer
18
+ * is NOT wired into the factory, that source is skipped silently
19
+ * and the remaining sources still surface):
20
+ *
21
+ * order — order_transitions rows joined back to the
22
+ * owning customer via orders.customer_id. The
23
+ * FSM events (mark_paid, mark_shipped,
24
+ * mark_delivered, cancel, refund) become
25
+ * "Order placed", "Payment received", "Order
26
+ * shipped", etc.
27
+ * wishlist — wishlist_entries rows keyed directly by
28
+ * customer_id. Each row contributes a single
29
+ * "wishlist.added" event at created_at.
30
+ * loyalty — loyalty_transactions rows keyed directly by
31
+ * customer_id. The transaction_type column drives
32
+ * the event kind ("loyalty.earn",
33
+ * "loyalty.redeem", "loyalty.expire", etc.).
34
+ * supportTickets — support_tickets rows owned by customer_id,
35
+ * each contributing an "support.opened" event
36
+ * at opened_at plus a "support.resolved" event
37
+ * when resolved_at is stamped.
38
+ * reviews — reviews rows joined by customer_id (the
39
+ * authenticated-submission column on the
40
+ * reviews table; anonymous email-hash reviews
41
+ * stay out of this feed because they aren't
42
+ * attributable to a known customer_id).
43
+ *
44
+ * Surface:
45
+ *
46
+ * forCustomer({ customer_id, from?, to?, kinds?, cursor?, limit? })
47
+ * — returns `[{ kind, occurred_at, title, body, link?, actor? }]`
48
+ * newest-first. `from`/`to` bound the window (epoch-ms);
49
+ * `kinds` is an optional whitelist of event-kind strings;
50
+ * `cursor`/`limit` paginate (cursor is an opaque
51
+ * HMAC-tagged string carrying the tail position).
52
+ *
53
+ * recentActivity({ limit })
54
+ * — flattened cross-customer feed for the operator dashboard.
55
+ * Newest-first across every customer with cache rows; falls
56
+ * back to walking the live sources when the cache is cold.
57
+ *
58
+ * summarize({ customer_id })
59
+ * — returns `{ last_activity_at, kind_counts_30d,
60
+ * kind_counts_90d, kind_counts_365d }`. Reads
61
+ * through the cache when fresh; recomputes from sources and
62
+ * refreshes the cache when not.
63
+ *
64
+ * lastActivityAt(customer_id)
65
+ * — convenience read for the customer-detail page header
66
+ * strip. Returns the newest source-event timestamp across
67
+ * every wired source, or `null` when the customer has no
68
+ * activity at all.
69
+ *
70
+ * inactiveCustomers({ days, limit })
71
+ * — operator outreach hint. Returns up to `limit` customers
72
+ * whose `last_activity_at` is older than `days` ago, newest-
73
+ * first by last_activity_at so the freshest stale customers
74
+ * surface before deeply-dormant ones.
75
+ *
76
+ * Read-only:
77
+ * This primitive WRITES exactly one table:
78
+ * `customer_activity_cache`, and only as a memoization side-
79
+ * effect of summarize() / lastActivityAt() / inactiveCustomers().
80
+ * Every event row lives in its source primitive's table.
81
+ *
82
+ * Composition (b.* primitives in use):
83
+ * - b.guardUuid — every customer_id input passes through the
84
+ * strict profile so a hostile cursor can't
85
+ * smuggle SQL fragments through.
86
+ * - b.pagination — HMAC-tagged tuple cursors for forCustomer().
87
+ *
88
+ * @primitive customerActivity
89
+ * @related shop.order, shop.wishlist, shop.loyalty,
90
+ * shop.supportTickets, shop.reviews
91
+ */
92
+
93
+ var bShop;
94
+ function _b() {
95
+ if (!bShop) bShop = require("./index");
96
+ return bShop.framework;
97
+ }
98
+
99
+ // ---- constants ----------------------------------------------------------
100
+
101
+ var MAX_LIMIT = 200;
102
+ var DEFAULT_LIMIT = 50;
103
+ var MAX_INACTIVE_LIMIT = 500;
104
+ var DEFAULT_INACTIVE_LIMIT = 100;
105
+
106
+ var MS_PER_DAY = 86400000;
107
+ var WINDOW_30D = 30 * MS_PER_DAY;
108
+ var WINDOW_90D = 90 * MS_PER_DAY;
109
+ var WINDOW_365D = 365 * MS_PER_DAY;
110
+
111
+ // Cache freshness window. summarize() returns the cached row when
112
+ // computed_at is within this window AND no source has a newer event
113
+ // than last_activity_at. Same posture as order-timeline's cache.
114
+ var CACHE_TTL_MS = 5 * 60 * 1000;
115
+
116
+ // Order-FSM events → canonical English titles. Events not in this
117
+ // map fall through with the raw event name as the title.
118
+ var ORDER_EVENT_TITLES = {
119
+ "create": "Order placed",
120
+ "mark_paid": "Payment received",
121
+ "start_fulfillment": "Preparing for shipment",
122
+ "mark_shipped": "Order shipped",
123
+ "mark_delivered": "Order delivered",
124
+ "cancel": "Order cancelled",
125
+ "refund": "Order refunded",
126
+ };
127
+
128
+ var LOYALTY_TITLES = {
129
+ "earn": "Loyalty points earned",
130
+ "redeem": "Loyalty points redeemed",
131
+ "expire": "Loyalty points expired",
132
+ "adjust": "Loyalty balance adjusted",
133
+ "tier-bonus": "Loyalty tier bonus",
134
+ };
135
+
136
+ // Order-key for the forCustomer() pagination cursor — (occurred_at
137
+ // DESC, kind DESC). Tuple keeps the cursor monotonic even when two
138
+ // events land in the same epoch-ms.
139
+ var LIST_ORDER_KEY = ["occurred_at", "kind"];
140
+
141
+ // ---- validators ---------------------------------------------------------
142
+
143
+ function _uuid(s, label) {
144
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
145
+ catch (e) { throw new TypeError("customerActivity: " + label + " — " + (e && e.message || "invalid UUID")); }
146
+ }
147
+
148
+ function _limit(n, max, def) {
149
+ if (n == null) return def;
150
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
151
+ throw new TypeError("customerActivity: limit must be an integer 1..." + max);
152
+ }
153
+ return n;
154
+ }
155
+
156
+ function _epochMs(v, label) {
157
+ if (v == null) return null;
158
+ if (!Number.isInteger(v) || v < 0) {
159
+ throw new TypeError("customerActivity: " + label + " must be a non-negative integer (epoch-ms) when provided");
160
+ }
161
+ return v;
162
+ }
163
+
164
+ function _kinds(arr) {
165
+ if (arr == null) return null;
166
+ if (!Array.isArray(arr)) {
167
+ throw new TypeError("customerActivity: kinds must be an array of strings when provided");
168
+ }
169
+ if (arr.length === 0) return null;
170
+ if (arr.length > 64) {
171
+ throw new TypeError("customerActivity: kinds may carry at most 64 entries");
172
+ }
173
+ var out = [];
174
+ var seen = {};
175
+ for (var i = 0; i < arr.length; i += 1) {
176
+ var k = arr[i];
177
+ if (typeof k !== "string" || !k.length || k.length > 128) {
178
+ throw new TypeError("customerActivity: kinds[" + i + "] must be a non-empty string up to 128 chars");
179
+ }
180
+ // Kind tokens are alnum + dot + underscore + hyphen. Refuses
181
+ // smuggling SQL / glob fragments through the filter list.
182
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(k)) {
183
+ throw new TypeError("customerActivity: kinds[" + i + "] must be alnum/./_/- only, starting alnum");
184
+ }
185
+ if (seen[k]) continue;
186
+ seen[k] = true;
187
+ out.push(k);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function _days(n) {
193
+ if (!Number.isInteger(n) || n <= 0 || n > 3650) {
194
+ throw new TypeError("customerActivity: days must be an integer 1...3650");
195
+ }
196
+ return n;
197
+ }
198
+
199
+ function _now() { return Date.now(); }
200
+
201
+ // ---- factory ------------------------------------------------------------
202
+
203
+ function create(opts) {
204
+ opts = opts || {};
205
+ var query = opts.query;
206
+ if (!query) {
207
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
208
+ }
209
+
210
+ // Pagination cursor secret. Production deployments derive one via
211
+ // `b.crypto.namespaceHash("customer-activity-cursor", D1_BRIDGE_SECRET)`;
212
+ // dev / test gets a stable placeholder so the cursor decodes
213
+ // without env wiring.
214
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
215
+ if (process.env.NODE_ENV === "production") {
216
+ throw new Error("customerActivity.create: opts.cursorSecret is required in production");
217
+ }
218
+ opts.cursorSecret = "customer-activity-cursor-secret-dev-only";
219
+ }
220
+ var cursorSecret = opts.cursorSecret;
221
+
222
+ // Injected peers — each optional. The marker value (a truthy
223
+ // object, including a peer's actual `create()` return) flips the
224
+ // matching collector on. The collector reads the source's
225
+ // canonical table directly so it doesn't depend on a particular
226
+ // method on the peer object — same posture order-timeline uses.
227
+ var orderPeer = opts.order || null;
228
+ var wishlistPeer = opts.wishlist || null;
229
+ var loyaltyPeer = opts.loyalty || null;
230
+ var supportPeer = opts.supportTickets || null;
231
+ var reviewsPeer = opts.reviews || null;
232
+
233
+ // Per-factory monotonic clock for cache computed_at stamps. Two
234
+ // cache refreshes for the same customer in the same wall-clock
235
+ // millisecond would otherwise tie on computed_at and confuse the
236
+ // freshness check. Forward-leap when wall outpaces the counter;
237
+ // otherwise bump by 1ms so the sequence stays strictly increasing
238
+ // per primitive instance.
239
+ var _lastTs = 0;
240
+ function _monotonicTs() {
241
+ var wall = _now();
242
+ if (wall > _lastTs) _lastTs = wall;
243
+ else _lastTs += 1;
244
+ return _lastTs;
245
+ }
246
+
247
+ // ---- per-source collectors --------------------------------------------
248
+ //
249
+ // Each collector returns an array of `{ kind, occurred_at, title,
250
+ // body, actor, link }` records (newest-first ordering happens at
251
+ // the aggregator). Missing peers collapse to an empty list so the
252
+ // aggregator stays the same shape regardless of wiring.
253
+
254
+ async function _collectOrderEvents(customerId, fromTs, toTs) {
255
+ if (!orderPeer) return [];
256
+ var sql = "SELECT ot.order_id, ot.from_state, ot.to_state, ot.on_event, " +
257
+ "ot.reason, ot.occurred_at " +
258
+ "FROM order_transitions ot " +
259
+ "JOIN orders o ON o.id = ot.order_id " +
260
+ "WHERE o.customer_id = ?1";
261
+ var params = [customerId];
262
+ var idx = 2;
263
+ if (fromTs != null) { sql += " AND ot.occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
264
+ if (toTs != null) { sql += " AND ot.occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
265
+ sql += " ORDER BY ot.occurred_at ASC";
266
+ var rows = (await query(sql, params)).rows;
267
+ var out = [];
268
+ for (var i = 0; i < rows.length; i += 1) {
269
+ var r = rows[i];
270
+ var title = ORDER_EVENT_TITLES[r.on_event] || r.on_event;
271
+ var body = r.reason ? r.reason : (r.from_state + " -> " + r.to_state);
272
+ out.push({
273
+ kind: "order." + r.on_event,
274
+ occurred_at: Number(r.occurred_at),
275
+ title: title,
276
+ body: body,
277
+ actor: "system",
278
+ link: "/operator/orders/" + r.order_id,
279
+ });
280
+ }
281
+ return out;
282
+ }
283
+
284
+ async function _collectWishlistEvents(customerId, fromTs, toTs) {
285
+ if (!wishlistPeer) return [];
286
+ var sql = "SELECT id, product_id, variant_id, notes, created_at " +
287
+ "FROM wishlist_entries WHERE customer_id = ?1";
288
+ var params = [customerId];
289
+ var idx = 2;
290
+ if (fromTs != null) { sql += " AND created_at >= ?" + idx; params.push(fromTs); idx += 1; }
291
+ if (toTs != null) { sql += " AND created_at <= ?" + idx; params.push(toTs); idx += 1; }
292
+ sql += " ORDER BY created_at ASC";
293
+ var rows = (await query(sql, params)).rows;
294
+ var out = [];
295
+ for (var i = 0; i < rows.length; i += 1) {
296
+ var r = rows[i];
297
+ out.push({
298
+ kind: "wishlist.added",
299
+ occurred_at: Number(r.created_at),
300
+ title: "Wishlisted an item",
301
+ body: r.notes ? r.notes : null,
302
+ actor: "customer",
303
+ link: "/operator/products/" + r.product_id,
304
+ });
305
+ }
306
+ return out;
307
+ }
308
+
309
+ async function _collectLoyaltyEvents(customerId, fromTs, toTs) {
310
+ if (!loyaltyPeer) return [];
311
+ var sql = "SELECT id, transaction_type, points, source, order_id, notes, " +
312
+ "occurred_at FROM loyalty_transactions WHERE customer_id = ?1";
313
+ var params = [customerId];
314
+ var idx = 2;
315
+ if (fromTs != null) { sql += " AND occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
316
+ if (toTs != null) { sql += " AND occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
317
+ sql += " ORDER BY occurred_at ASC";
318
+ var rows = (await query(sql, params)).rows;
319
+ var out = [];
320
+ for (var i = 0; i < rows.length; i += 1) {
321
+ var r = rows[i];
322
+ var pts = Number(r.points) || 0;
323
+ var sign = pts > 0 ? "+" : "";
324
+ var body = sign + pts + " pts" + (r.source ? " (" + r.source + ")" : "");
325
+ out.push({
326
+ kind: "loyalty." + r.transaction_type,
327
+ occurred_at: Number(r.occurred_at),
328
+ title: LOYALTY_TITLES[r.transaction_type] || ("Loyalty " + r.transaction_type),
329
+ body: body,
330
+ actor: "system",
331
+ link: r.order_id ? ("/operator/orders/" + r.order_id) : null,
332
+ });
333
+ }
334
+ return out;
335
+ }
336
+
337
+ async function _collectSupportEvents(customerId, fromTs, toTs) {
338
+ if (!supportPeer) return [];
339
+ // The support_tickets row contributes an "opened" event at
340
+ // opened_at and a "resolved" event at resolved_at when stamped.
341
+ // The bounded-window filter is applied per-event after
342
+ // splitting (a ticket opened inside the window but resolved
343
+ // outside still surfaces its opened event).
344
+ var rows = (await query(
345
+ "SELECT id, subject, category, status, priority, opened_at, " +
346
+ "resolved_at, closed_at FROM support_tickets WHERE customer_id = ?1 " +
347
+ "ORDER BY opened_at ASC",
348
+ [customerId],
349
+ )).rows;
350
+ var out = [];
351
+ for (var i = 0; i < rows.length; i += 1) {
352
+ var t = rows[i];
353
+ var openedAt = Number(t.opened_at);
354
+ if ((fromTs == null || openedAt >= fromTs) && (toTs == null || openedAt <= toTs)) {
355
+ out.push({
356
+ kind: "support.opened",
357
+ occurred_at: openedAt,
358
+ title: "Support ticket opened",
359
+ body: t.subject + " [" + t.category + "/" + t.priority + "]",
360
+ actor: "customer",
361
+ link: "/operator/support/" + t.id,
362
+ });
363
+ }
364
+ if (t.resolved_at != null) {
365
+ var resolvedAt = Number(t.resolved_at);
366
+ if ((fromTs == null || resolvedAt >= fromTs) && (toTs == null || resolvedAt <= toTs)) {
367
+ out.push({
368
+ kind: "support.resolved",
369
+ occurred_at: resolvedAt,
370
+ title: "Support ticket resolved",
371
+ body: t.subject,
372
+ actor: "operator",
373
+ link: "/operator/support/" + t.id,
374
+ });
375
+ }
376
+ }
377
+ }
378
+ return out;
379
+ }
380
+
381
+ async function _collectReviewEvents(customerId, fromTs, toTs) {
382
+ if (!reviewsPeer) return [];
383
+ // Only authenticated-customer reviews carry a customer_id (the
384
+ // anonymous email-hash submissions land in the reviews table
385
+ // with customer_id NULL and stay out of this feed because
386
+ // they're not attributable to a known customer_id).
387
+ var sql = "SELECT id, product_id, rating, title, status, created_at " +
388
+ "FROM reviews WHERE customer_id = ?1";
389
+ var params = [customerId];
390
+ var idx = 2;
391
+ if (fromTs != null) { sql += " AND created_at >= ?" + idx; params.push(fromTs); idx += 1; }
392
+ if (toTs != null) { sql += " AND created_at <= ?" + idx; params.push(toTs); idx += 1; }
393
+ sql += " ORDER BY created_at ASC";
394
+ var rows = (await query(sql, params)).rows;
395
+ var out = [];
396
+ for (var i = 0; i < rows.length; i += 1) {
397
+ var r = rows[i];
398
+ out.push({
399
+ kind: "review." + r.status,
400
+ occurred_at: Number(r.created_at),
401
+ title: "Review submitted",
402
+ body: r.rating + "/5 — " + r.title,
403
+ actor: "customer",
404
+ link: "/operator/products/" + r.product_id + "#review-" + r.id,
405
+ });
406
+ }
407
+ return out;
408
+ }
409
+
410
+ // ---- aggregation ------------------------------------------------------
411
+
412
+ async function _collectAll(customerId, fromTs, toTs) {
413
+ var batches = await Promise.all([
414
+ _collectOrderEvents(customerId, fromTs, toTs),
415
+ _collectWishlistEvents(customerId, fromTs, toTs),
416
+ _collectLoyaltyEvents(customerId, fromTs, toTs),
417
+ _collectSupportEvents(customerId, fromTs, toTs),
418
+ _collectReviewEvents(customerId, fromTs, toTs),
419
+ ]);
420
+ var flat = [];
421
+ for (var b = 0; b < batches.length; b += 1) {
422
+ for (var i = 0; i < batches[b].length; i += 1) {
423
+ flat.push(batches[b][i]);
424
+ }
425
+ }
426
+ return flat;
427
+ }
428
+
429
+ function _sortNewestFirst(events) {
430
+ events.sort(function (a, b) {
431
+ if (b.occurred_at !== a.occurred_at) return b.occurred_at - a.occurred_at;
432
+ // Tie-break on kind so the order is deterministic across
433
+ // platforms when two events land in the same epoch-ms.
434
+ if (a.kind < b.kind) return 1;
435
+ if (a.kind > b.kind) return -1;
436
+ return 0;
437
+ });
438
+ return events;
439
+ }
440
+
441
+ function _filterKinds(events, kinds) {
442
+ if (!kinds) return events;
443
+ var set = {};
444
+ for (var i = 0; i < kinds.length; i += 1) set[kinds[i]] = true;
445
+ return events.filter(function (e) { return set[e.kind] === true; });
446
+ }
447
+
448
+ // Apply the cursor tail-tuple. Cursor carries `[occurred_at, kind]`
449
+ // of the last row of the previous page; the next page starts at
450
+ // the first row that's strictly older in the (occurred_at DESC,
451
+ // kind DESC) ordering.
452
+ function _applyCursor(events, cursorVals) {
453
+ if (!cursorVals) return events;
454
+ var ts = Number(cursorVals[0]);
455
+ var kind = String(cursorVals[1]);
456
+ var out = [];
457
+ for (var i = 0; i < events.length; i += 1) {
458
+ var e = events[i];
459
+ if (e.occurred_at < ts) { out.push(e); continue; }
460
+ if (e.occurred_at === ts && e.kind < kind) { out.push(e); continue; }
461
+ }
462
+ return out;
463
+ }
464
+
465
+ function _encodeNext(rows, limit) {
466
+ var last = rows[rows.length - 1];
467
+ if (!last || rows.length < limit) return null;
468
+ return _b().pagination.encodeCursor({
469
+ orderKey: LIST_ORDER_KEY,
470
+ vals: [last.occurred_at, last.kind],
471
+ forward: true,
472
+ }, cursorSecret);
473
+ }
474
+
475
+ function _decodeCursor(cursor, label) {
476
+ if (cursor == null) return null;
477
+ if (typeof cursor !== "string") {
478
+ throw new TypeError("customerActivity." + label + ": cursor must be an opaque string or null");
479
+ }
480
+ try {
481
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
482
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
483
+ throw new TypeError("customerActivity." + label + ": cursor orderKey mismatch");
484
+ }
485
+ return state.vals;
486
+ } catch (e) {
487
+ if (e instanceof TypeError) throw e;
488
+ throw new TypeError("customerActivity." + label + ": cursor — " + (e && e.message || "malformed"));
489
+ }
490
+ }
491
+
492
+ // Roll up per-source freshness stats so the cache freshness check
493
+ // can detect a new source-event that lands in the same epoch-ms
494
+ // as the cache row.
495
+ async function _sourceStats(customerId) {
496
+ var checks = [];
497
+ if (orderPeer) {
498
+ checks.push(query(
499
+ "SELECT MAX(ot.occurred_at) AS m, COUNT(*) AS n " +
500
+ "FROM order_transitions ot JOIN orders o ON o.id = ot.order_id " +
501
+ "WHERE o.customer_id = ?1",
502
+ [customerId],
503
+ ));
504
+ }
505
+ if (wishlistPeer) {
506
+ checks.push(query(
507
+ "SELECT MAX(created_at) AS m, COUNT(*) AS n FROM wishlist_entries WHERE customer_id = ?1",
508
+ [customerId],
509
+ ));
510
+ }
511
+ if (loyaltyPeer) {
512
+ checks.push(query(
513
+ "SELECT MAX(occurred_at) AS m, COUNT(*) AS n FROM loyalty_transactions WHERE customer_id = ?1",
514
+ [customerId],
515
+ ));
516
+ }
517
+ if (supportPeer) {
518
+ checks.push(query(
519
+ "SELECT " +
520
+ "(SELECT MAX(opened_at) FROM support_tickets WHERE customer_id = ?1) AS c1, " +
521
+ "(SELECT MAX(resolved_at) FROM support_tickets WHERE customer_id = ?1) AS c2, " +
522
+ "(SELECT COUNT(*) FROM support_tickets WHERE customer_id = ?1) AS n",
523
+ [customerId],
524
+ ));
525
+ }
526
+ if (reviewsPeer) {
527
+ checks.push(query(
528
+ "SELECT MAX(created_at) AS m, COUNT(*) AS n FROM reviews WHERE customer_id = ?1",
529
+ [customerId],
530
+ ));
531
+ }
532
+ var results = await Promise.all(checks);
533
+ var latest = 0;
534
+ var totalCount = 0;
535
+ for (var i = 0; i < results.length; i += 1) {
536
+ var rs = results[i].rows;
537
+ if (!rs.length) continue;
538
+ var row = rs[0];
539
+ var keys = Object.keys(row);
540
+ for (var k = 0; k < keys.length; k += 1) {
541
+ var key = keys[k];
542
+ var raw = row[key];
543
+ if (raw == null) continue;
544
+ if (key === "n") { totalCount += Number(raw) || 0; continue; }
545
+ var v = Number(raw);
546
+ if (Number.isFinite(v) && v > latest) latest = v;
547
+ }
548
+ }
549
+ return { latest: latest, totalCount: totalCount };
550
+ }
551
+
552
+ async function _cacheGet(customerId) {
553
+ var r = await query(
554
+ "SELECT customer_id, last_activity_at, kind_counts_30d_json, " +
555
+ "kind_counts_90d_json, kind_counts_365d_json, computed_at " +
556
+ "FROM customer_activity_cache WHERE customer_id = ?1",
557
+ [customerId],
558
+ );
559
+ return r.rows[0] || null;
560
+ }
561
+
562
+ async function _cachePut(customerId, summary) {
563
+ var ts = _monotonicTs();
564
+ // INSERT-or-REPLACE pattern: DELETE-then-INSERT so the cache
565
+ // works on engines that lack ON CONFLICT.
566
+ await query("DELETE FROM customer_activity_cache WHERE customer_id = ?1", [customerId]);
567
+ await query(
568
+ "INSERT INTO customer_activity_cache " +
569
+ "(customer_id, last_activity_at, kind_counts_30d_json, " +
570
+ "kind_counts_90d_json, kind_counts_365d_json, computed_at) " +
571
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
572
+ [
573
+ customerId,
574
+ summary.last_activity_at || 0,
575
+ JSON.stringify(summary.kind_counts_30d || {}),
576
+ JSON.stringify(summary.kind_counts_90d || {}),
577
+ JSON.stringify(summary.kind_counts_365d || {}),
578
+ ts,
579
+ ],
580
+ );
581
+ }
582
+
583
+ async function _cacheIsFresh(cacheRow, nowTs) {
584
+ if (!cacheRow) return false;
585
+ if (nowTs - Number(cacheRow.computed_at) > CACHE_TTL_MS) return false;
586
+ var stats = await _sourceStats(cacheRow.customer_id);
587
+ if (stats.latest > Number(cacheRow.last_activity_at)) return false;
588
+ return true;
589
+ }
590
+
591
+ function _countByKindInWindow(events, nowTs, windowMs) {
592
+ var cutoff = nowTs - windowMs;
593
+ var counts = {};
594
+ for (var i = 0; i < events.length; i += 1) {
595
+ var e = events[i];
596
+ if (e.occurred_at < cutoff) continue;
597
+ counts[e.kind] = (counts[e.kind] || 0) + 1;
598
+ }
599
+ return counts;
600
+ }
601
+
602
+ async function _computeSummary(customerId, nowTs) {
603
+ var events = await _collectAll(customerId, null, null);
604
+ _sortNewestFirst(events);
605
+ var last = events.length ? events[0].occurred_at : 0;
606
+ return {
607
+ last_activity_at: last,
608
+ kind_counts_30d: _countByKindInWindow(events, nowTs, WINDOW_30D),
609
+ kind_counts_90d: _countByKindInWindow(events, nowTs, WINDOW_90D),
610
+ kind_counts_365d: _countByKindInWindow(events, nowTs, WINDOW_365D),
611
+ };
612
+ }
613
+
614
+ function _stripInternalFields(events) {
615
+ var out = [];
616
+ for (var i = 0; i < events.length; i += 1) {
617
+ var e = events[i];
618
+ out.push({
619
+ kind: e.kind,
620
+ occurred_at: e.occurred_at,
621
+ title: e.title,
622
+ body: e.body == null ? null : e.body,
623
+ actor: e.actor || null,
624
+ link: e.link || null,
625
+ });
626
+ }
627
+ return out;
628
+ }
629
+
630
+ // ---- public surface ---------------------------------------------------
631
+
632
+ return {
633
+ CACHE_TTL_MS: CACHE_TTL_MS,
634
+ MAX_LIMIT: MAX_LIMIT,
635
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
636
+ MAX_INACTIVE_LIMIT: MAX_INACTIVE_LIMIT,
637
+ DEFAULT_INACTIVE_LIMIT: DEFAULT_INACTIVE_LIMIT,
638
+
639
+ forCustomer: async function (input) {
640
+ if (!input || typeof input !== "object") {
641
+ throw new TypeError("customerActivity.forCustomer: input object required");
642
+ }
643
+ var customerId = _uuid(input.customer_id, "customer_id");
644
+ var fromTs = _epochMs(input.from, "from");
645
+ var toTs = _epochMs(input.to, "to");
646
+ if (fromTs != null && toTs != null && fromTs > toTs) {
647
+ throw new TypeError("customerActivity.forCustomer: from must be <= to");
648
+ }
649
+ var kinds = _kinds(input.kinds);
650
+ var limit = _limit(input.limit, MAX_LIMIT, DEFAULT_LIMIT);
651
+ var cursor = _decodeCursor(input.cursor, "forCustomer");
652
+
653
+ var events = await _collectAll(customerId, fromTs, toTs);
654
+ events = _filterKinds(events, kinds);
655
+ _sortNewestFirst(events);
656
+ if (cursor) events = _applyCursor(events, cursor);
657
+ var page = events.slice(0, limit);
658
+ return {
659
+ events: _stripInternalFields(page),
660
+ next_cursor: _encodeNext(page, limit),
661
+ };
662
+ },
663
+
664
+ recentActivity: async function (listOpts) {
665
+ if (!listOpts || typeof listOpts !== "object") {
666
+ throw new TypeError("customerActivity.recentActivity: options object required");
667
+ }
668
+ var limit = _limit(listOpts.limit, MAX_LIMIT, DEFAULT_LIMIT);
669
+
670
+ // Scope through the cache when it's been warmed; otherwise
671
+ // fall back to walking every customer that has at least one
672
+ // event in a wired source. The cache fast-path keeps the
673
+ // dashboard cheap once an operator has loaded any per-customer
674
+ // summary; the slow-path keeps the surface useful on a cold
675
+ // install.
676
+ var cacheRows = (await query(
677
+ "SELECT customer_id FROM customer_activity_cache " +
678
+ "ORDER BY last_activity_at DESC LIMIT ?1",
679
+ [limit],
680
+ )).rows;
681
+
682
+ var customerIds = [];
683
+ var seen = {};
684
+ for (var i = 0; i < cacheRows.length; i += 1) {
685
+ var cid = cacheRows[i].customer_id;
686
+ if (seen[cid]) continue;
687
+ seen[cid] = true;
688
+ customerIds.push(cid);
689
+ }
690
+
691
+ // Cold-path discovery: if the cache yielded fewer customers
692
+ // than the requested limit, walk each wired source's distinct
693
+ // customer_id list to fill the gap. The operator dashboard
694
+ // doesn't care that the cache hasn't been warmed for these
695
+ // customers yet — the events still exist in the source tables.
696
+ if (customerIds.length < limit) {
697
+ var sourceCustomerSqls = [];
698
+ if (orderPeer) sourceCustomerSqls.push("SELECT DISTINCT customer_id AS cid FROM orders WHERE customer_id IS NOT NULL");
699
+ if (wishlistPeer) sourceCustomerSqls.push("SELECT DISTINCT customer_id AS cid FROM wishlist_entries");
700
+ if (loyaltyPeer) sourceCustomerSqls.push("SELECT DISTINCT customer_id AS cid FROM loyalty_transactions");
701
+ if (supportPeer) sourceCustomerSqls.push("SELECT DISTINCT customer_id AS cid FROM support_tickets WHERE customer_id IS NOT NULL");
702
+ if (reviewsPeer) sourceCustomerSqls.push("SELECT DISTINCT customer_id AS cid FROM reviews WHERE customer_id IS NOT NULL");
703
+ for (var s = 0; s < sourceCustomerSqls.length; s += 1) {
704
+ var srows = (await query(sourceCustomerSqls[s], [])).rows;
705
+ for (var k = 0; k < srows.length; k += 1) {
706
+ var c = srows[k].cid;
707
+ if (!c || seen[c]) continue;
708
+ seen[c] = true;
709
+ customerIds.push(c);
710
+ if (customerIds.length >= limit * 4) break; // bounded discovery scope
711
+ }
712
+ if (customerIds.length >= limit * 4) break;
713
+ }
714
+ }
715
+
716
+ var merged = [];
717
+ for (var m = 0; m < customerIds.length; m += 1) {
718
+ var cid2 = customerIds[m];
719
+ var ev = await _collectAll(cid2, null, null);
720
+ for (var j = 0; j < ev.length; j += 1) {
721
+ var rec = ev[j];
722
+ merged.push({
723
+ customer_id: cid2,
724
+ kind: rec.kind,
725
+ occurred_at: rec.occurred_at,
726
+ title: rec.title,
727
+ body: rec.body == null ? null : rec.body,
728
+ actor: rec.actor || null,
729
+ link: rec.link || null,
730
+ });
731
+ }
732
+ }
733
+ merged.sort(function (x, y) {
734
+ if (y.occurred_at !== x.occurred_at) return y.occurred_at - x.occurred_at;
735
+ if (x.kind < y.kind) return 1;
736
+ if (x.kind > y.kind) return -1;
737
+ return 0;
738
+ });
739
+ if (merged.length > limit) merged.length = limit;
740
+ return merged;
741
+ },
742
+
743
+ summarize: async function (input) {
744
+ if (!input || typeof input !== "object") {
745
+ throw new TypeError("customerActivity.summarize: input object required");
746
+ }
747
+ var customerId = _uuid(input.customer_id, "customer_id");
748
+ var nowTs = _now();
749
+ var cached = await _cacheGet(customerId);
750
+ if (await _cacheIsFresh(cached, nowTs)) {
751
+ var c30 = {};
752
+ var c90 = {};
753
+ var c365 = {};
754
+ try { c30 = JSON.parse(cached.kind_counts_30d_json || "{}"); }
755
+ catch (_e) { c30 = {}; } // drop-silent — bad cache JSON falls through to fresh recompute on next call
756
+ try { c90 = JSON.parse(cached.kind_counts_90d_json || "{}"); }
757
+ catch (_e) { c90 = {}; } // drop-silent — see above
758
+ try { c365 = JSON.parse(cached.kind_counts_365d_json || "{}"); }
759
+ catch (_e) { c365 = {}; } // drop-silent — see above
760
+ return {
761
+ last_activity_at: Number(cached.last_activity_at) || null,
762
+ kind_counts_30d: c30,
763
+ kind_counts_90d: c90,
764
+ kind_counts_365d: c365,
765
+ cache_hit: true,
766
+ };
767
+ }
768
+ var summary = await _computeSummary(customerId, nowTs);
769
+ await _cachePut(customerId, summary);
770
+ return {
771
+ last_activity_at: summary.last_activity_at || null,
772
+ kind_counts_30d: summary.kind_counts_30d,
773
+ kind_counts_90d: summary.kind_counts_90d,
774
+ kind_counts_365d: summary.kind_counts_365d,
775
+ cache_hit: false,
776
+ };
777
+ },
778
+
779
+ lastActivityAt: async function (customerId) {
780
+ var cid = _uuid(customerId, "customer_id");
781
+ var cached = await _cacheGet(cid);
782
+ var nowTs = _now();
783
+ if (await _cacheIsFresh(cached, nowTs)) {
784
+ var v = Number(cached.last_activity_at);
785
+ return v > 0 ? v : null;
786
+ }
787
+ var stats = await _sourceStats(cid);
788
+ var latest = stats.latest > 0 ? stats.latest : null;
789
+ // Best-effort cache refresh so the next call hits the cached
790
+ // row. lastActivityAt() is the cheapest summarize-equivalent;
791
+ // populating the cache here lets the operator-dashboard
792
+ // recent-customers strip avoid an N-deep source recompute on
793
+ // each render.
794
+ if (latest != null) {
795
+ var summary = await _computeSummary(cid, nowTs);
796
+ await _cachePut(cid, summary);
797
+ }
798
+ return latest;
799
+ },
800
+
801
+ inactiveCustomers: async function (listOpts) {
802
+ if (!listOpts || typeof listOpts !== "object") {
803
+ throw new TypeError("customerActivity.inactiveCustomers: options object required");
804
+ }
805
+ var days = _days(listOpts.days);
806
+ var limit = _limit(listOpts.limit, MAX_INACTIVE_LIMIT, DEFAULT_INACTIVE_LIMIT);
807
+ var cutoff = _now() - (days * MS_PER_DAY);
808
+ var rows = (await query(
809
+ "SELECT customer_id, last_activity_at FROM customer_activity_cache " +
810
+ "WHERE last_activity_at < ?1 AND last_activity_at > 0 " +
811
+ "ORDER BY last_activity_at DESC LIMIT ?2",
812
+ [cutoff, limit],
813
+ )).rows;
814
+ var out = [];
815
+ for (var i = 0; i < rows.length; i += 1) {
816
+ out.push({
817
+ customer_id: rows[i].customer_id,
818
+ last_activity_at: Number(rows[i].last_activity_at),
819
+ });
820
+ }
821
+ return out;
822
+ },
823
+
824
+ // Operator-facing cache sweep — removes cache rows older than
825
+ // the bound. Useful as a periodic chore when an operator wants
826
+ // the dashboard to recompute from sources for every customer
827
+ // (e.g. after a bulk import).
828
+ purgeStaleCache: async function (olderThanMs) {
829
+ var bound = Number.isInteger(olderThanMs) && olderThanMs > 0
830
+ ? olderThanMs
831
+ : CACHE_TTL_MS * 12;
832
+ var cutoff = _now() - bound;
833
+ var r = await query(
834
+ "DELETE FROM customer_activity_cache WHERE computed_at < ?1",
835
+ [cutoff],
836
+ );
837
+ return { purged: Number(r.rowCount || 0) };
838
+ },
839
+ };
840
+ }
841
+
842
+ // Async run() entry point — invoked by harnesses that load the
843
+ // primitive as a unit (e.g. a smoke gate). Builds a default factory
844
+ // against `b.externalDb` so callers don't need to know about the
845
+ // peer injection surface to verify the module loads cleanly.
846
+ async function run() {
847
+ var instance = create({});
848
+ return {
849
+ ok: true,
850
+ surface: Object.keys(instance),
851
+ };
852
+ }
853
+
854
+ module.exports = {
855
+ create: create,
856
+ run: run,
857
+ CACHE_TTL_MS: CACHE_TTL_MS,
858
+ MAX_LIMIT: MAX_LIMIT,
859
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
860
+ MAX_INACTIVE_LIMIT: MAX_INACTIVE_LIMIT,
861
+ DEFAULT_INACTIVE_LIMIT: DEFAULT_INACTIVE_LIMIT,
862
+ };