@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.
- package/CHANGELOG.md +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- 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
|
+
};
|