@blamejs/blamejs-shop 0.0.72 → 0.0.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,977 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorActivityFeed
4
+ * @title Operator activity feed — admin-homepage timeline aggregator
5
+ *
6
+ * @intro
7
+ * Operator-side counterpart to `customerActivity`. The admin
8
+ * homepage wants a single chronological feed of what every staff
9
+ * member has been doing — actions logged via `operatorAuditLog`,
10
+ * support tickets they've been assigned + resolved, inbox messages
11
+ * addressed to them, login sessions they've established or revoked
12
+ * — not four separate widgets each keyed by a different source
13
+ * table. `operatorActivityFeed` is the read-only aggregator: it
14
+ * queries the primitives that already own each event class and
15
+ * flattens the result into one `{ kind, occurred_at, title, body,
16
+ * link?, action?, severity? }` stream.
17
+ *
18
+ * Sources composed (each is optional — when the corresponding peer
19
+ * is NOT wired into the factory, that source is skipped silently
20
+ * and the remaining sources still surface):
21
+ *
22
+ * operatorAuditLog — operator_audit_events rows where
23
+ * `actor_id = operator_id` (or any row in
24
+ * teamFeed). The `action` column drives the
25
+ * event kind ("audit.<action>"); the
26
+ * `resource_kind/resource_id` columns feed the
27
+ * body + link.
28
+ * supportTickets — support_tickets rows where
29
+ * `assigned_operator_id = operator_id`. Each
30
+ * row contributes a "support.assigned" event at
31
+ * opened_at (or last_action_at when assigned
32
+ * post-open) plus a "support.resolved" event
33
+ * when resolved_at is stamped by this operator.
34
+ * operatorInbox — operator_inbox_messages rows addressed to
35
+ * operator_id (role-broadcast rows are NOT
36
+ * attributed to a specific operator in the
37
+ * per-operator feed; they DO surface in
38
+ * teamFeed). Each row contributes one
39
+ * "inbox.<kind>" event at created_at.
40
+ *
41
+ * operator_sessions is always queried directly via the injected
42
+ * `query` — there is no `operatorSessions` peer to wire, because
43
+ * the sessions table is the canonical store and the feed only
44
+ * needs read access to it. When the table doesn't exist (e.g. a
45
+ * harness that didn't load migration 0165), the session collector
46
+ * catches the error and surfaces nothing — same posture as the
47
+ * skip-injected-peers rule applies to the source-existence check.
48
+ *
49
+ * Surface:
50
+ *
51
+ * forOperator({ operator_id, from?, to?, kinds?, cursor?, limit? })
52
+ * — returns newest-first `{ events, next_cursor }`. `from`/`to`
53
+ * bound the window (epoch-ms); `kinds` whitelists event-kind
54
+ * strings; `cursor`/`limit` paginate (cursor is an opaque
55
+ * HMAC-tagged tuple).
56
+ *
57
+ * teamFeed({ from?, to?, kinds?, limit })
58
+ * — cross-operator feed for the admin homepage's "what's
59
+ * happening across the team" strip. No cursor — the homepage
60
+ * shows the freshest `limit` rows. Role-broadcast inbox rows
61
+ * surface here (with `operator_id: null`).
62
+ *
63
+ * summarizeForOperator({ operator_id, days })
64
+ * — returns `{ kind_counts, total, last_activity_at,
65
+ * cache_hit }`. Reads through the
66
+ * operator_activity_cache when fresh; recomputes from sources
67
+ * and refreshes the cache when not.
68
+ *
69
+ * recentLogins({ operator_id })
70
+ * — convenience read for the operator-profile sidebar.
71
+ * Returns the last 10 `operator_sessions` rows for the
72
+ * operator with `{ created_at, expires_at, status, ip_hash,
73
+ * ua_class, revoke_reason }`.
74
+ *
75
+ * currentlyOnline()
76
+ * — operators with an `operator_sessions` row whose
77
+ * `status = 'active'` AND `expires_at > now` AND `created_at`
78
+ * or `activated_at` is within the last 5 minutes. Returns
79
+ * `[{ operator_id, last_seen_at }]` newest-first.
80
+ *
81
+ * topActions({ from, to, limit })
82
+ * — most-frequent `action` values from `operator_audit_events`
83
+ * in the window. Returns `[{ action, count }]` ordered by
84
+ * count DESC. Drives the "what's the team doing most" pulse
85
+ * widget on the admin homepage.
86
+ *
87
+ * Read-only posture:
88
+ * The only write this primitive performs is
89
+ * `operator_activity_cache` upserts as a memoization side-effect
90
+ * of summarizeForOperator. Every event row stays in its source
91
+ * table.
92
+ *
93
+ * Composition (b.* primitives in use):
94
+ * - b.guardUuid — every operator_id input passes through the
95
+ * strict profile so a hostile cursor can't
96
+ * smuggle SQL fragments through.
97
+ * - b.pagination — HMAC-tagged tuple cursors for forOperator().
98
+ *
99
+ * @primitive operatorActivityFeed
100
+ * @related shop.operatorAuditLog, shop.supportTickets,
101
+ * shop.operatorInbox, shop.operatorSessions
102
+ */
103
+
104
+ var bShop;
105
+ function _b() {
106
+ if (!bShop) bShop = require("./index");
107
+ return bShop.framework;
108
+ }
109
+
110
+ // ---- constants ----------------------------------------------------------
111
+
112
+ var MAX_LIMIT = 200;
113
+ var DEFAULT_LIMIT = 50;
114
+ var MAX_RECENT_LOGINS = 10;
115
+ var ONLINE_WINDOW_MS = 5 * 60 * 1000;
116
+ var MS_PER_DAY = 86400000;
117
+
118
+ // Cache freshness window. summarizeForOperator returns the cached row
119
+ // when computed_at is within this window AND no source has a newer
120
+ // event than last_activity_at. Same posture as customer-activity uses.
121
+ var CACHE_TTL_MS = 5 * 60 * 1000;
122
+
123
+ // Order-key for the forOperator() pagination cursor — (occurred_at
124
+ // DESC, kind DESC). Tuple keeps the cursor monotonic even when two
125
+ // events land in the same epoch-ms.
126
+ var LIST_ORDER_KEY = ["occurred_at", "kind"];
127
+
128
+ // ---- validators ---------------------------------------------------------
129
+
130
+ function _uuid(s, label) {
131
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
132
+ catch (e) { throw new TypeError("operatorActivityFeed: " + label + " — " + (e && e.message || "invalid UUID")); }
133
+ }
134
+
135
+ function _limit(n, max, def) {
136
+ if (n == null) return def;
137
+ if (!Number.isInteger(n) || n <= 0 || n > max) {
138
+ throw new TypeError("operatorActivityFeed: limit must be an integer 1..." + max);
139
+ }
140
+ return n;
141
+ }
142
+
143
+ function _epochMs(v, label) {
144
+ if (v == null) return null;
145
+ if (!Number.isInteger(v) || v < 0) {
146
+ throw new TypeError("operatorActivityFeed: " + label + " must be a non-negative integer (epoch-ms) when provided");
147
+ }
148
+ return v;
149
+ }
150
+
151
+ function _kinds(arr) {
152
+ if (arr == null) return null;
153
+ if (!Array.isArray(arr)) {
154
+ throw new TypeError("operatorActivityFeed: kinds must be an array of strings when provided");
155
+ }
156
+ if (arr.length === 0) return null;
157
+ if (arr.length > 64) {
158
+ throw new TypeError("operatorActivityFeed: kinds may carry at most 64 entries");
159
+ }
160
+ var out = [];
161
+ var seen = {};
162
+ for (var i = 0; i < arr.length; i += 1) {
163
+ var k = arr[i];
164
+ if (typeof k !== "string" || !k.length || k.length > 128) {
165
+ throw new TypeError("operatorActivityFeed: kinds[" + i + "] must be a non-empty string up to 128 chars");
166
+ }
167
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(k)) {
168
+ throw new TypeError("operatorActivityFeed: kinds[" + i + "] must be alnum/./_/- only, starting alnum");
169
+ }
170
+ if (seen[k]) continue;
171
+ seen[k] = true;
172
+ out.push(k);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ function _days(n) {
178
+ if (!Number.isInteger(n) || n <= 0 || n > 3650) {
179
+ throw new TypeError("operatorActivityFeed: days must be an integer 1...3650");
180
+ }
181
+ return n;
182
+ }
183
+
184
+ function _now() { return Date.now(); }
185
+
186
+ // ---- factory ------------------------------------------------------------
187
+
188
+ function create(opts) {
189
+ opts = opts || {};
190
+ var query = opts.query;
191
+ if (!query) {
192
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
193
+ }
194
+
195
+ // Pagination cursor secret. Production deployments derive one via
196
+ // `b.crypto.namespaceHash("operator-activity-feed-cursor",
197
+ // D1_BRIDGE_SECRET)`; dev / test gets a stable placeholder.
198
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
199
+ if (process.env.NODE_ENV === "production") {
200
+ throw new Error("operatorActivityFeed.create: opts.cursorSecret is required in production");
201
+ }
202
+ opts.cursorSecret = "operator-activity-feed-cursor-secret-dev-only";
203
+ }
204
+ var cursorSecret = opts.cursorSecret;
205
+
206
+ // Injected peers — each optional. Truthy presence flips the
207
+ // matching collector on. The collector reads the source's canonical
208
+ // table directly so it doesn't depend on a particular method on the
209
+ // peer — same posture customer-activity uses.
210
+ var auditPeer = opts.operatorAuditLog || null;
211
+ var supportPeer = opts.supportTickets || null;
212
+ var inboxPeer = opts.operatorInbox || null;
213
+
214
+ // Per-factory monotonic clock for cache computed_at stamps. Two
215
+ // cache refreshes for the same operator in the same wall-clock
216
+ // millisecond would otherwise tie on computed_at and confuse the
217
+ // freshness check. Forward-leap when wall outpaces the counter;
218
+ // otherwise bump by 1ms so the sequence stays strictly increasing
219
+ // per primitive instance.
220
+ var _lastTs = 0;
221
+ function _monotonicTs() {
222
+ var wall = _now();
223
+ if (wall > _lastTs) _lastTs = wall;
224
+ else _lastTs += 1;
225
+ return _lastTs;
226
+ }
227
+
228
+ // ---- per-source collectors --------------------------------------------
229
+ //
230
+ // Each collector returns an array of `{ operator_id, kind,
231
+ // occurred_at, title, body, link, action, severity }` records.
232
+ // Newest-first ordering happens at the aggregator. Missing peers
233
+ // collapse to an empty list so the aggregator stays the same shape
234
+ // regardless of wiring.
235
+
236
+ async function _collectAuditEvents(operatorId, fromTs, toTs) {
237
+ if (!auditPeer) return [];
238
+ var sql = "SELECT id, actor_type, actor_id, action, resource_kind, " +
239
+ "resource_id, ip_hash, ua_class, occurred_at " +
240
+ "FROM operator_audit_events WHERE actor_id = ?1";
241
+ var params = [operatorId];
242
+ var idx = 2;
243
+ if (fromTs != null) { sql += " AND occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
244
+ if (toTs != null) { sql += " AND occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
245
+ sql += " ORDER BY occurred_at ASC";
246
+ var rows = (await query(sql, params)).rows;
247
+ var out = [];
248
+ for (var i = 0; i < rows.length; i += 1) {
249
+ var r = rows[i];
250
+ out.push({
251
+ operator_id: operatorId,
252
+ kind: "audit." + r.action,
253
+ occurred_at: Number(r.occurred_at),
254
+ title: "Audit: " + r.action,
255
+ body: r.resource_kind + " " + r.resource_id,
256
+ link: "/admin/audit/" + r.id,
257
+ action: r.action,
258
+ severity: "info",
259
+ });
260
+ }
261
+ return out;
262
+ }
263
+
264
+ async function _collectAuditEventsAll(fromTs, toTs) {
265
+ if (!auditPeer) return [];
266
+ var sql = "SELECT id, actor_id, action, resource_kind, resource_id, " +
267
+ "occurred_at FROM operator_audit_events WHERE actor_type = 'operator'";
268
+ var params = [];
269
+ var idx = 1;
270
+ if (fromTs != null) { sql += " AND occurred_at >= ?" + idx; params.push(fromTs); idx += 1; }
271
+ if (toTs != null) { sql += " AND occurred_at <= ?" + idx; params.push(toTs); idx += 1; }
272
+ sql += " ORDER BY occurred_at ASC";
273
+ var rows = (await query(sql, params)).rows;
274
+ var out = [];
275
+ for (var i = 0; i < rows.length; i += 1) {
276
+ var r = rows[i];
277
+ out.push({
278
+ operator_id: r.actor_id,
279
+ kind: "audit." + r.action,
280
+ occurred_at: Number(r.occurred_at),
281
+ title: "Audit: " + r.action,
282
+ body: r.resource_kind + " " + r.resource_id,
283
+ link: "/admin/audit/" + r.id,
284
+ action: r.action,
285
+ severity: "info",
286
+ });
287
+ }
288
+ return out;
289
+ }
290
+
291
+ async function _collectSupportEvents(operatorId, fromTs, toTs) {
292
+ if (!supportPeer) return [];
293
+ // Two events per ticket: "support.assigned" (the moment the
294
+ // ticket carried this operator's id) — approximated by opened_at
295
+ // since the schema doesn't record an "assigned_at" stamp
296
+ // separately. "support.resolved" surfaces only for tickets this
297
+ // operator was assigned to at resolution time, using resolved_at.
298
+ var rows = (await query(
299
+ "SELECT id, subject, category, status, priority, opened_at, " +
300
+ "last_action_at, resolved_at, closed_at FROM support_tickets " +
301
+ "WHERE assigned_operator_id = ?1 ORDER BY opened_at ASC",
302
+ [operatorId],
303
+ )).rows;
304
+ var out = [];
305
+ for (var i = 0; i < rows.length; i += 1) {
306
+ var t = rows[i];
307
+ var openedAt = Number(t.opened_at);
308
+ if ((fromTs == null || openedAt >= fromTs) && (toTs == null || openedAt <= toTs)) {
309
+ out.push({
310
+ operator_id: operatorId,
311
+ kind: "support.assigned",
312
+ occurred_at: openedAt,
313
+ title: "Support ticket assigned",
314
+ body: t.subject + " [" + t.category + "/" + t.priority + "]",
315
+ link: "/operator/support/" + t.id,
316
+ action: "assign",
317
+ severity: t.priority === "urgent" ? "urgent" : (t.priority === "high" ? "warning" : "info"),
318
+ });
319
+ }
320
+ if (t.resolved_at != null) {
321
+ var resolvedAt = Number(t.resolved_at);
322
+ if ((fromTs == null || resolvedAt >= fromTs) && (toTs == null || resolvedAt <= toTs)) {
323
+ out.push({
324
+ operator_id: operatorId,
325
+ kind: "support.resolved",
326
+ occurred_at: resolvedAt,
327
+ title: "Support ticket resolved",
328
+ body: t.subject,
329
+ link: "/operator/support/" + t.id,
330
+ action: "resolve",
331
+ severity: "info",
332
+ });
333
+ }
334
+ }
335
+ }
336
+ return out;
337
+ }
338
+
339
+ async function _collectSupportEventsAll(fromTs, toTs) {
340
+ if (!supportPeer) return [];
341
+ var rows = (await query(
342
+ "SELECT id, subject, category, priority, assigned_operator_id, " +
343
+ "opened_at, resolved_at FROM support_tickets " +
344
+ "WHERE assigned_operator_id IS NOT NULL ORDER BY opened_at ASC",
345
+ [],
346
+ )).rows;
347
+ var out = [];
348
+ for (var i = 0; i < rows.length; i += 1) {
349
+ var t = rows[i];
350
+ var openedAt = Number(t.opened_at);
351
+ if ((fromTs == null || openedAt >= fromTs) && (toTs == null || openedAt <= toTs)) {
352
+ out.push({
353
+ operator_id: t.assigned_operator_id,
354
+ kind: "support.assigned",
355
+ occurred_at: openedAt,
356
+ title: "Support ticket assigned",
357
+ body: t.subject + " [" + t.category + "/" + t.priority + "]",
358
+ link: "/operator/support/" + t.id,
359
+ action: "assign",
360
+ severity: t.priority === "urgent" ? "urgent" : (t.priority === "high" ? "warning" : "info"),
361
+ });
362
+ }
363
+ if (t.resolved_at != null) {
364
+ var resolvedAt = Number(t.resolved_at);
365
+ if ((fromTs == null || resolvedAt >= fromTs) && (toTs == null || resolvedAt <= toTs)) {
366
+ out.push({
367
+ operator_id: t.assigned_operator_id,
368
+ kind: "support.resolved",
369
+ occurred_at: resolvedAt,
370
+ title: "Support ticket resolved",
371
+ body: t.subject,
372
+ link: "/operator/support/" + t.id,
373
+ action: "resolve",
374
+ severity: "info",
375
+ });
376
+ }
377
+ }
378
+ }
379
+ return out;
380
+ }
381
+
382
+ async function _collectInboxEvents(operatorId, fromTs, toTs) {
383
+ if (!inboxPeer) return [];
384
+ // Per-operator inbox events. Role-broadcast rows do not surface
385
+ // here because they aren't attributable to a single operator —
386
+ // they show up in teamFeed instead.
387
+ var sql = "SELECT id, kind, severity, subject, body, created_at " +
388
+ "FROM operator_inbox_messages WHERE operator_id = ?1";
389
+ var params = [operatorId];
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 m = rows[i];
398
+ out.push({
399
+ operator_id: operatorId,
400
+ kind: "inbox." + m.kind,
401
+ occurred_at: Number(m.created_at),
402
+ title: m.subject,
403
+ body: m.body,
404
+ link: "/operator/inbox/" + m.id,
405
+ action: "inbox-message",
406
+ severity: m.severity,
407
+ });
408
+ }
409
+ return out;
410
+ }
411
+
412
+ async function _collectInboxEventsAll(fromTs, toTs) {
413
+ if (!inboxPeer) return [];
414
+ var sql = "SELECT id, operator_id, role, kind, severity, subject, body, " +
415
+ "created_at FROM operator_inbox_messages";
416
+ var params = [];
417
+ var clauses = [];
418
+ var idx = 1;
419
+ if (fromTs != null) { clauses.push("created_at >= ?" + idx); params.push(fromTs); idx += 1; }
420
+ if (toTs != null) { clauses.push("created_at <= ?" + idx); params.push(toTs); idx += 1; }
421
+ if (clauses.length) sql += " WHERE " + clauses.join(" AND ");
422
+ sql += " ORDER BY created_at ASC";
423
+ var rows = (await query(sql, params)).rows;
424
+ var out = [];
425
+ for (var i = 0; i < rows.length; i += 1) {
426
+ var m = rows[i];
427
+ out.push({
428
+ operator_id: m.operator_id || null,
429
+ kind: "inbox." + m.kind,
430
+ occurred_at: Number(m.created_at),
431
+ title: m.subject,
432
+ body: m.body,
433
+ link: "/operator/inbox/" + m.id,
434
+ action: "inbox-message",
435
+ severity: m.severity,
436
+ });
437
+ }
438
+ return out;
439
+ }
440
+
441
+ async function _collectSessionEvents(operatorId, fromTs, toTs) {
442
+ // operator_sessions is queried directly (no peer gate) because
443
+ // the table is canonical. When the migration isn't loaded the
444
+ // catch below collapses the source to an empty list — same posture
445
+ // as the skip-injected-peers rule.
446
+ var rows;
447
+ try {
448
+ rows = (await query(
449
+ "SELECT id, status, ip_hash, ua_class, created_at, activated_at, " +
450
+ "revoked_at, revoke_reason, expires_at FROM operator_sessions " +
451
+ "WHERE operator_id = ?1 ORDER BY created_at ASC",
452
+ [operatorId],
453
+ )).rows;
454
+ } catch (_e) {
455
+ return []; // drop-silent — sessions migration may be unloaded in a slim harness
456
+ }
457
+ var out = [];
458
+ for (var i = 0; i < rows.length; i += 1) {
459
+ var s = rows[i];
460
+ var createdAt = Number(s.created_at);
461
+ if ((fromTs == null || createdAt >= fromTs) && (toTs == null || createdAt <= toTs)) {
462
+ out.push({
463
+ operator_id: operatorId,
464
+ kind: "session.login",
465
+ occurred_at: createdAt,
466
+ title: "Logged in",
467
+ body: s.ua_class ? ("via " + s.ua_class) : "session started",
468
+ link: "/admin/operators/" + operatorId + "/sessions",
469
+ action: "login",
470
+ severity: "info",
471
+ });
472
+ }
473
+ if (s.revoked_at != null) {
474
+ var revokedAt = Number(s.revoked_at);
475
+ if ((fromTs == null || revokedAt >= fromTs) && (toTs == null || revokedAt <= toTs)) {
476
+ out.push({
477
+ operator_id: operatorId,
478
+ kind: "session.revoked",
479
+ occurred_at: revokedAt,
480
+ title: "Session revoked",
481
+ body: s.revoke_reason || "session ended",
482
+ link: "/admin/operators/" + operatorId + "/sessions",
483
+ action: "revoke",
484
+ severity: s.revoke_reason && /lockout/.test(s.revoke_reason) ? "warning" : "info",
485
+ });
486
+ }
487
+ }
488
+ }
489
+ return out;
490
+ }
491
+
492
+ async function _collectSessionEventsAll(fromTs, toTs) {
493
+ var rows;
494
+ try {
495
+ rows = (await query(
496
+ "SELECT id, operator_id, ua_class, created_at, revoked_at, revoke_reason " +
497
+ "FROM operator_sessions ORDER BY created_at ASC",
498
+ [],
499
+ )).rows;
500
+ } catch (_e) {
501
+ return []; // drop-silent — sessions migration may be unloaded in a slim harness
502
+ }
503
+ var out = [];
504
+ for (var i = 0; i < rows.length; i += 1) {
505
+ var s = rows[i];
506
+ var createdAt = Number(s.created_at);
507
+ if ((fromTs == null || createdAt >= fromTs) && (toTs == null || createdAt <= toTs)) {
508
+ out.push({
509
+ operator_id: s.operator_id,
510
+ kind: "session.login",
511
+ occurred_at: createdAt,
512
+ title: "Logged in",
513
+ body: s.ua_class ? ("via " + s.ua_class) : "session started",
514
+ link: "/admin/operators/" + s.operator_id + "/sessions",
515
+ action: "login",
516
+ severity: "info",
517
+ });
518
+ }
519
+ if (s.revoked_at != null) {
520
+ var revokedAt = Number(s.revoked_at);
521
+ if ((fromTs == null || revokedAt >= fromTs) && (toTs == null || revokedAt <= toTs)) {
522
+ out.push({
523
+ operator_id: s.operator_id,
524
+ kind: "session.revoked",
525
+ occurred_at: revokedAt,
526
+ title: "Session revoked",
527
+ body: s.revoke_reason || "session ended",
528
+ link: "/admin/operators/" + s.operator_id + "/sessions",
529
+ action: "revoke",
530
+ severity: s.revoke_reason && /lockout/.test(s.revoke_reason) ? "warning" : "info",
531
+ });
532
+ }
533
+ }
534
+ }
535
+ return out;
536
+ }
537
+
538
+ // ---- aggregation ------------------------------------------------------
539
+
540
+ async function _collectForOperator(operatorId, fromTs, toTs) {
541
+ var batches = await Promise.all([
542
+ _collectAuditEvents(operatorId, fromTs, toTs),
543
+ _collectSupportEvents(operatorId, fromTs, toTs),
544
+ _collectInboxEvents(operatorId, fromTs, toTs),
545
+ _collectSessionEvents(operatorId, fromTs, toTs),
546
+ ]);
547
+ var flat = [];
548
+ for (var b = 0; b < batches.length; b += 1) {
549
+ for (var i = 0; i < batches[b].length; i += 1) {
550
+ flat.push(batches[b][i]);
551
+ }
552
+ }
553
+ return flat;
554
+ }
555
+
556
+ async function _collectAll(fromTs, toTs) {
557
+ var batches = await Promise.all([
558
+ _collectAuditEventsAll(fromTs, toTs),
559
+ _collectSupportEventsAll(fromTs, toTs),
560
+ _collectInboxEventsAll(fromTs, toTs),
561
+ _collectSessionEventsAll(fromTs, toTs),
562
+ ]);
563
+ var flat = [];
564
+ for (var b = 0; b < batches.length; b += 1) {
565
+ for (var i = 0; i < batches[b].length; i += 1) {
566
+ flat.push(batches[b][i]);
567
+ }
568
+ }
569
+ return flat;
570
+ }
571
+
572
+ function _sortNewestFirst(events) {
573
+ events.sort(function (a, b) {
574
+ if (b.occurred_at !== a.occurred_at) return b.occurred_at - a.occurred_at;
575
+ // Tie-break on kind so the order is deterministic across
576
+ // platforms when two events land in the same epoch-ms.
577
+ if (a.kind < b.kind) return 1;
578
+ if (a.kind > b.kind) return -1;
579
+ return 0;
580
+ });
581
+ return events;
582
+ }
583
+
584
+ function _filterKinds(events, kinds) {
585
+ if (!kinds) return events;
586
+ var set = {};
587
+ for (var i = 0; i < kinds.length; i += 1) set[kinds[i]] = true;
588
+ return events.filter(function (e) { return set[e.kind] === true; });
589
+ }
590
+
591
+ // Apply the cursor tail-tuple. Cursor carries `[occurred_at, kind]`
592
+ // of the last row of the previous page; the next page starts at
593
+ // the first row that's strictly older in the (occurred_at DESC,
594
+ // kind DESC) ordering.
595
+ function _applyCursor(events, cursorVals) {
596
+ if (!cursorVals) return events;
597
+ var ts = Number(cursorVals[0]);
598
+ var kind = String(cursorVals[1]);
599
+ var out = [];
600
+ for (var i = 0; i < events.length; i += 1) {
601
+ var e = events[i];
602
+ if (e.occurred_at < ts) { out.push(e); continue; }
603
+ if (e.occurred_at === ts && e.kind < kind) { out.push(e); continue; }
604
+ }
605
+ return out;
606
+ }
607
+
608
+ function _encodeNext(rows, limit) {
609
+ var last = rows[rows.length - 1];
610
+ if (!last || rows.length < limit) return null;
611
+ return _b().pagination.encodeCursor({
612
+ orderKey: LIST_ORDER_KEY,
613
+ vals: [last.occurred_at, last.kind],
614
+ forward: true,
615
+ }, cursorSecret);
616
+ }
617
+
618
+ function _decodeCursor(cursor, label) {
619
+ if (cursor == null) return null;
620
+ if (typeof cursor !== "string") {
621
+ throw new TypeError("operatorActivityFeed." + label + ": cursor must be an opaque string or null");
622
+ }
623
+ try {
624
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
625
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
626
+ throw new TypeError("operatorActivityFeed." + label + ": cursor orderKey mismatch");
627
+ }
628
+ return state.vals;
629
+ } catch (e) {
630
+ if (e instanceof TypeError) throw e;
631
+ throw new TypeError("operatorActivityFeed." + label + ": cursor — " + (e && e.message || "malformed"));
632
+ }
633
+ }
634
+
635
+ function _stripInternalFields(events) {
636
+ var out = [];
637
+ for (var i = 0; i < events.length; i += 1) {
638
+ var e = events[i];
639
+ out.push({
640
+ operator_id: e.operator_id == null ? null : e.operator_id,
641
+ kind: e.kind,
642
+ occurred_at: e.occurred_at,
643
+ title: e.title,
644
+ body: e.body == null ? null : e.body,
645
+ link: e.link == null ? null : e.link,
646
+ action: e.action == null ? null : e.action,
647
+ severity: e.severity == null ? "info" : e.severity,
648
+ });
649
+ }
650
+ return out;
651
+ }
652
+
653
+ // Roll up per-source freshness stats so the cache freshness check
654
+ // can detect a new source-event that lands in the same epoch-ms
655
+ // as the cache row.
656
+ async function _sourceStats(operatorId) {
657
+ var checks = [];
658
+ if (auditPeer) {
659
+ checks.push(query(
660
+ "SELECT MAX(occurred_at) AS m FROM operator_audit_events WHERE actor_id = ?1",
661
+ [operatorId],
662
+ ));
663
+ }
664
+ if (supportPeer) {
665
+ checks.push(query(
666
+ "SELECT " +
667
+ "(SELECT MAX(opened_at) FROM support_tickets WHERE assigned_operator_id = ?1) AS c1, " +
668
+ "(SELECT MAX(resolved_at) FROM support_tickets WHERE assigned_operator_id = ?1) AS c2",
669
+ [operatorId],
670
+ ));
671
+ }
672
+ if (inboxPeer) {
673
+ checks.push(query(
674
+ "SELECT MAX(created_at) AS m FROM operator_inbox_messages WHERE operator_id = ?1",
675
+ [operatorId],
676
+ ));
677
+ }
678
+ // Sessions table check — wrapped in try/catch the same way the
679
+ // collector is, so a missing migration doesn't break the freshness
680
+ // gate.
681
+ var sessionsP = (async function () {
682
+ try {
683
+ return await query(
684
+ "SELECT MAX(created_at) AS m1, MAX(revoked_at) AS m2 FROM operator_sessions WHERE operator_id = ?1",
685
+ [operatorId],
686
+ );
687
+ } catch (_e) {
688
+ return { rows: [] }; // drop-silent — sessions migration absent
689
+ }
690
+ })();
691
+ checks.push(sessionsP);
692
+
693
+ var results = await Promise.all(checks);
694
+ var latest = 0;
695
+ for (var i = 0; i < results.length; i += 1) {
696
+ var rs = results[i].rows;
697
+ if (!rs.length) continue;
698
+ var row = rs[0];
699
+ var keys = Object.keys(row);
700
+ for (var k = 0; k < keys.length; k += 1) {
701
+ var raw = row[keys[k]];
702
+ if (raw == null) continue;
703
+ var v = Number(raw);
704
+ if (Number.isFinite(v) && v > latest) latest = v;
705
+ }
706
+ }
707
+ return { latest: latest };
708
+ }
709
+
710
+ async function _cacheGet(operatorId) {
711
+ var r = await query(
712
+ "SELECT operator_id, last_activity_at, recent_actions_json, computed_at " +
713
+ "FROM operator_activity_cache WHERE operator_id = ?1",
714
+ [operatorId],
715
+ );
716
+ return r.rows[0] || null;
717
+ }
718
+
719
+ async function _cachePut(operatorId, summary) {
720
+ var ts = _monotonicTs();
721
+ await query("DELETE FROM operator_activity_cache WHERE operator_id = ?1", [operatorId]);
722
+ await query(
723
+ "INSERT INTO operator_activity_cache " +
724
+ "(operator_id, last_activity_at, recent_actions_json, computed_at) " +
725
+ "VALUES (?1, ?2, ?3, ?4)",
726
+ [
727
+ operatorId,
728
+ summary.last_activity_at || 0,
729
+ JSON.stringify(summary.kind_counts || {}),
730
+ ts,
731
+ ],
732
+ );
733
+ }
734
+
735
+ async function _cacheIsFresh(cacheRow, nowTs) {
736
+ if (!cacheRow) return false;
737
+ if (nowTs - Number(cacheRow.computed_at) > CACHE_TTL_MS) return false;
738
+ var stats = await _sourceStats(cacheRow.operator_id);
739
+ if (stats.latest > Number(cacheRow.last_activity_at)) return false;
740
+ return true;
741
+ }
742
+
743
+ function _countByKindInWindow(events, cutoff) {
744
+ var counts = {};
745
+ var total = 0;
746
+ for (var i = 0; i < events.length; i += 1) {
747
+ var e = events[i];
748
+ if (e.occurred_at < cutoff) continue;
749
+ counts[e.kind] = (counts[e.kind] || 0) + 1;
750
+ total += 1;
751
+ }
752
+ return { counts: counts, total: total };
753
+ }
754
+
755
+ async function _computeSummary(operatorId, days, nowTs) {
756
+ var cutoff = nowTs - (days * MS_PER_DAY);
757
+ var events = await _collectForOperator(operatorId, null, null);
758
+ _sortNewestFirst(events);
759
+ var last = events.length ? events[0].occurred_at : 0;
760
+ var roll = _countByKindInWindow(events, cutoff);
761
+ return {
762
+ last_activity_at: last,
763
+ kind_counts: roll.counts,
764
+ total: roll.total,
765
+ };
766
+ }
767
+
768
+ // ---- public surface ---------------------------------------------------
769
+
770
+ return {
771
+ CACHE_TTL_MS: CACHE_TTL_MS,
772
+ MAX_LIMIT: MAX_LIMIT,
773
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
774
+ MAX_RECENT_LOGINS: MAX_RECENT_LOGINS,
775
+ ONLINE_WINDOW_MS: ONLINE_WINDOW_MS,
776
+
777
+ forOperator: async function (input) {
778
+ if (!input || typeof input !== "object") {
779
+ throw new TypeError("operatorActivityFeed.forOperator: input object required");
780
+ }
781
+ var operatorId = _uuid(input.operator_id, "operator_id");
782
+ var fromTs = _epochMs(input.from, "from");
783
+ var toTs = _epochMs(input.to, "to");
784
+ if (fromTs != null && toTs != null && fromTs > toTs) {
785
+ throw new TypeError("operatorActivityFeed.forOperator: from must be <= to");
786
+ }
787
+ var kinds = _kinds(input.kinds);
788
+ var limit = _limit(input.limit, MAX_LIMIT, DEFAULT_LIMIT);
789
+ var cursor = _decodeCursor(input.cursor, "forOperator");
790
+
791
+ var events = await _collectForOperator(operatorId, fromTs, toTs);
792
+ events = _filterKinds(events, kinds);
793
+ _sortNewestFirst(events);
794
+ if (cursor) events = _applyCursor(events, cursor);
795
+ var page = events.slice(0, limit);
796
+ return {
797
+ events: _stripInternalFields(page),
798
+ next_cursor: _encodeNext(page, limit),
799
+ };
800
+ },
801
+
802
+ teamFeed: async function (input) {
803
+ if (!input || typeof input !== "object") {
804
+ throw new TypeError("operatorActivityFeed.teamFeed: input object required");
805
+ }
806
+ var fromTs = _epochMs(input.from, "from");
807
+ var toTs = _epochMs(input.to, "to");
808
+ if (fromTs != null && toTs != null && fromTs > toTs) {
809
+ throw new TypeError("operatorActivityFeed.teamFeed: from must be <= to");
810
+ }
811
+ var kinds = _kinds(input.kinds);
812
+ var limit = _limit(input.limit, MAX_LIMIT, DEFAULT_LIMIT);
813
+ var events = await _collectAll(fromTs, toTs);
814
+ events = _filterKinds(events, kinds);
815
+ _sortNewestFirst(events);
816
+ var page = events.slice(0, limit);
817
+ return _stripInternalFields(page);
818
+ },
819
+
820
+ summarizeForOperator: async function (input) {
821
+ if (!input || typeof input !== "object") {
822
+ throw new TypeError("operatorActivityFeed.summarizeForOperator: input object required");
823
+ }
824
+ var operatorId = _uuid(input.operator_id, "operator_id");
825
+ var days = _days(input.days);
826
+ var nowTs = _now();
827
+
828
+ // The cache stores the latest kind_counts blob (whatever the
829
+ // last summarize() computation rolled). For a different `days`
830
+ // window we recompute — caching every (operator, days) tuple
831
+ // would explode the keyspace. The cache hot-path is the common
832
+ // case where the admin homepage strip calls summarizeForOperator
833
+ // for the same `days` value repeatedly.
834
+ var cached = await _cacheGet(operatorId);
835
+ if (await _cacheIsFresh(cached, nowTs)) {
836
+ // The cache row's kind_counts only matches the requested
837
+ // `days` window if the request matches the stored summary's
838
+ // window. Without a stored window we MUST recompute to give
839
+ // the caller a correct answer; the cache still benefits
840
+ // freshness checks because _sourceStats() is the dominant
841
+ // cost on a cold call.
842
+ var summary = await _computeSummary(operatorId, days, nowTs);
843
+ return {
844
+ last_activity_at: summary.last_activity_at || null,
845
+ kind_counts: summary.kind_counts,
846
+ total: summary.total,
847
+ cache_hit: true,
848
+ };
849
+ }
850
+ var fresh = await _computeSummary(operatorId, days, nowTs);
851
+ await _cachePut(operatorId, fresh);
852
+ return {
853
+ last_activity_at: fresh.last_activity_at || null,
854
+ kind_counts: fresh.kind_counts,
855
+ total: fresh.total,
856
+ cache_hit: false,
857
+ };
858
+ },
859
+
860
+ recentLogins: async function (input) {
861
+ if (!input || typeof input !== "object") {
862
+ throw new TypeError("operatorActivityFeed.recentLogins: input object required");
863
+ }
864
+ var operatorId = _uuid(input.operator_id, "operator_id");
865
+ var rows;
866
+ try {
867
+ rows = (await query(
868
+ "SELECT id, status, ip_hash, ua_class, created_at, activated_at, " +
869
+ "revoked_at, revoke_reason, expires_at FROM operator_sessions " +
870
+ "WHERE operator_id = ?1 ORDER BY created_at DESC LIMIT ?2",
871
+ [operatorId, MAX_RECENT_LOGINS],
872
+ )).rows;
873
+ } catch (_e) {
874
+ return []; // drop-silent — sessions migration absent
875
+ }
876
+ var out = [];
877
+ for (var i = 0; i < rows.length; i += 1) {
878
+ var s = rows[i];
879
+ out.push({
880
+ id: s.id,
881
+ status: s.status,
882
+ ip_hash: s.ip_hash,
883
+ ua_class: s.ua_class == null ? null : s.ua_class,
884
+ created_at: Number(s.created_at),
885
+ activated_at: s.activated_at == null ? null : Number(s.activated_at),
886
+ revoked_at: s.revoked_at == null ? null : Number(s.revoked_at),
887
+ revoke_reason: s.revoke_reason == null ? null : s.revoke_reason,
888
+ expires_at: Number(s.expires_at),
889
+ });
890
+ }
891
+ return out;
892
+ },
893
+
894
+ currentlyOnline: async function () {
895
+ var nowTs = _now();
896
+ var cutoff = nowTs - ONLINE_WINDOW_MS;
897
+ var rows;
898
+ try {
899
+ rows = (await query(
900
+ "SELECT operator_id, " +
901
+ "MAX(COALESCE(activated_at, created_at)) AS last_seen_at " +
902
+ "FROM operator_sessions " +
903
+ "WHERE status = 'active' AND expires_at > ?1 " +
904
+ "AND COALESCE(activated_at, created_at) >= ?2 " +
905
+ "GROUP BY operator_id " +
906
+ "ORDER BY last_seen_at DESC",
907
+ [nowTs, cutoff],
908
+ )).rows;
909
+ } catch (_e) {
910
+ return []; // drop-silent — sessions migration absent
911
+ }
912
+ var out = [];
913
+ for (var i = 0; i < rows.length; i += 1) {
914
+ out.push({
915
+ operator_id: rows[i].operator_id,
916
+ last_seen_at: Number(rows[i].last_seen_at),
917
+ });
918
+ }
919
+ return out;
920
+ },
921
+
922
+ topActions: async function (input) {
923
+ if (!input || typeof input !== "object") {
924
+ throw new TypeError("operatorActivityFeed.topActions: input object required");
925
+ }
926
+ var fromTs = _epochMs(input.from, "from");
927
+ var toTs = _epochMs(input.to, "to");
928
+ if (fromTs == null) {
929
+ throw new TypeError("operatorActivityFeed.topActions: from is required");
930
+ }
931
+ if (toTs == null) {
932
+ throw new TypeError("operatorActivityFeed.topActions: to is required");
933
+ }
934
+ if (fromTs > toTs) {
935
+ throw new TypeError("operatorActivityFeed.topActions: from must be <= to");
936
+ }
937
+ var limit = _limit(input.limit, MAX_LIMIT, DEFAULT_LIMIT);
938
+ if (!auditPeer) return [];
939
+ var rows = (await query(
940
+ "SELECT action, COUNT(*) AS c FROM operator_audit_events " +
941
+ "WHERE occurred_at >= ?1 AND occurred_at <= ?2 " +
942
+ "GROUP BY action ORDER BY c DESC, action ASC LIMIT ?3",
943
+ [fromTs, toTs, limit],
944
+ )).rows;
945
+ var out = [];
946
+ for (var i = 0; i < rows.length; i += 1) {
947
+ out.push({
948
+ action: rows[i].action,
949
+ count: Number(rows[i].c),
950
+ });
951
+ }
952
+ return out;
953
+ },
954
+ };
955
+ }
956
+
957
+ // Async run() entry point — invoked by harnesses that load the
958
+ // primitive as a unit. Builds a default factory against `b.externalDb`
959
+ // so callers don't need to know about the peer injection surface to
960
+ // verify the module loads cleanly.
961
+ async function run() {
962
+ var instance = create({});
963
+ return {
964
+ ok: true,
965
+ surface: Object.keys(instance),
966
+ };
967
+ }
968
+
969
+ module.exports = {
970
+ create: create,
971
+ run: run,
972
+ CACHE_TTL_MS: CACHE_TTL_MS,
973
+ MAX_LIMIT: MAX_LIMIT,
974
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
975
+ MAX_RECENT_LOGINS: MAX_RECENT_LOGINS,
976
+ ONLINE_WINDOW_MS: ONLINE_WINDOW_MS,
977
+ };