@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,889 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.operatorInbox
4
+ * @title Operator inbox — per-operator notification feed for system
5
+ * events
6
+ *
7
+ * @intro
8
+ * Operators run the storefront through an admin console; the inbox
9
+ * primitive is the in-console "you have N unread messages" stream
10
+ * that surfaces system events (a refund attempt failed, a SKU has
11
+ * crossed its low-stock threshold, a security gate fired, an NPS
12
+ * response landed in the bottom decile, a vendor's payout queued).
13
+ *
14
+ * The application enqueues a message keyed to one of two
15
+ * addressing modes:
16
+ *
17
+ * - `operator_id` — a single named operator. Used when the event
18
+ * has an obvious owner (the support agent who issued the refund
19
+ * gets the refund-failure; the buyer of a vendor's product gets
20
+ * the vendor's payout settlement notification).
21
+ * - `role` — a broadcast to every operator carrying that role at
22
+ * the time of read. Used when the event has no specific owner
23
+ * (low-stock visible to anyone with `inventory.write`; security
24
+ * incident visible to anyone with `settings.write`). The
25
+ * `operatorRoles` peer is the source of truth for role-bearer
26
+ * membership; this primitive optionally consults it via the
27
+ * `operatorRoles` injection so `inboxForOperator` can fold in
28
+ * broadcasts the caller would receive.
29
+ *
30
+ * Exactly one addressing mode is set per message — the schema
31
+ * CHECK constraint enforces it.
32
+ *
33
+ * Severity is a four-level closed enum:
34
+ *
35
+ * - `info` — informational; default sort tie-break.
36
+ * - `warning` — action recommended but not urgent.
37
+ * - `urgent` — action expected this shift.
38
+ * - `critical` — page the on-call.
39
+ *
40
+ * The primitive does NOT itself dispatch off-channel notifications
41
+ * (email / SMS / push). It's the in-console feed only. A composed
42
+ * `notifications` peer can subscribe to inbox writes if the
43
+ * operator wants critical-severity rows mirrored to an off-channel
44
+ * pager.
45
+ *
46
+ * FSM (per message):
47
+ *
48
+ * created -> (read_at) -> read
49
+ * created -> archived
50
+ * read -> archived
51
+ * read -> (markUnread) -> created (operator can undo)
52
+ *
53
+ * `archived_at` is terminal as a visible-inbox concept: archived
54
+ * rows are excluded from `inboxForOperator` and `unreadCount`. They
55
+ * remain queryable via `metricsForKind` (so an operator-side audit
56
+ * that "no critical-severity refund_failure went unaddressed last
57
+ * week" can land). `cleanupOlderThan` is the disk-pressure
58
+ * release-valve; rows older than the supplied cutoff are deleted
59
+ * regardless of read / archived state.
60
+ *
61
+ * Severity-min filtering:
62
+ *
63
+ * `inboxForOperator({ severity_min: "warning" })` includes
64
+ * warning + urgent + critical (anything at or above the supplied
65
+ * floor). The severity rank is a closed integer ladder so the
66
+ * comparison is local — no operator-authored severity strings
67
+ * are accepted.
68
+ *
69
+ * Composes:
70
+ * - `b.uuid.v7` — message row PKs (lexicographic +
71
+ * monotonic so two same-millisecond
72
+ * enqueues retain the order they were
73
+ * issued).
74
+ * - `b.guardUuid` — UUID-shape gate on every `operator_id`
75
+ * entering the primitive.
76
+ * - `b.pagination` — HMAC-tagged tuple cursor for
77
+ * `inboxForOperator` (state =
78
+ * [created_at, id], forward-only).
79
+ * - `operatorRoles` — optional. When wired,
80
+ * `inboxForOperator` consults
81
+ * `rolesForOperator(operator_id)` and
82
+ * folds matching role-broadcast rows
83
+ * into the result. Absent, only the
84
+ * operator-id-addressed rows surface.
85
+ *
86
+ * Monotonic clock:
87
+ * Two enqueues landing in the same millisecond on a fast machine
88
+ * would otherwise tie on `created_at`, blurring the cursor sort
89
+ * and making per-millisecond ordering tests racy. The factory's
90
+ * `_now()` bumps by 1ms on collision so every row carries a
91
+ * strict-monotonic created_at — the (created_at DESC, id DESC)
92
+ * sort key is total.
93
+ *
94
+ * Surface:
95
+ *
96
+ * - enqueueMessage({ operator_id?, role?, kind, severity, subject,
97
+ * body, payload?, source_event_id? })
98
+ * Insert a row. Exactly one of operator_id / role required;
99
+ * BOTH or NEITHER refused. Returns the persisted row.
100
+ *
101
+ * - inboxForOperator({ operator_id, severity_min?, unread_only?,
102
+ * include_archived?, limit?, cursor? })
103
+ * Read the operator's inbox. Includes operator-id-addressed
104
+ * rows + (when `operatorRoles` is wired) role-broadcast rows
105
+ * the operator currently carries. `severity_min` floors the
106
+ * severity rank. `unread_only` filters `read_at IS NULL`.
107
+ * `include_archived` is opt-in (default excludes archived).
108
+ * HMAC-tagged cursor over (created_at, id) — forward-only.
109
+ *
110
+ * - markRead({ id, operator_id })
111
+ * Mark a single message read. Refuses if the message isn't
112
+ * addressable to the operator (neither operator-id match
113
+ * nor role-match via operatorRoles). Idempotent — already-
114
+ * read is a no-op.
115
+ *
116
+ * - markUnread({ id, operator_id })
117
+ * Inverse — clears `read_at`. Same addressability gate as
118
+ * markRead.
119
+ *
120
+ * - archiveMessage({ id, operator_id })
121
+ * Mark archived. Same addressability gate. Idempotent.
122
+ *
123
+ * - bulkArchive({ operator_id, ids })
124
+ * Archive a list. Refuses any id the operator can't address.
125
+ * Returns `{ archived_count }`.
126
+ *
127
+ * - unreadCount({ operator_id, severity_min? })
128
+ * Cheap count for the navbar badge. Excludes archived.
129
+ *
130
+ * - cleanupOlderThan({ now?, age_ms })
131
+ * Delete rows whose `created_at < (now - age_ms)`. Used to
132
+ * keep the table bounded — the inbox is a feed, not an
133
+ * archive of record.
134
+ *
135
+ * - metricsForKind({ kind, from, to })
136
+ * Per-(severity) histogram + read-rate + median-time-to-read
137
+ * for one kind over an [from, to) window. Reads archived
138
+ * rows too — an operator-side audit ("did we read every
139
+ * critical refund_failure last week?") needs them. Returns
140
+ * `{ kind, from, to, total, by_severity, by_state }`.
141
+ *
142
+ * Storage: `migrations-d1/0175_operator_inbox.sql`.
143
+ *
144
+ * @primitive operatorInbox
145
+ * @related b.uuid, b.guardUuid, b.pagination, shop.operatorRoles
146
+ */
147
+
148
+ var bShop;
149
+ function _b() {
150
+ if (!bShop) bShop = require("./index");
151
+ return bShop.framework;
152
+ }
153
+
154
+ // ---- constants ----------------------------------------------------------
155
+
156
+ var SEVERITIES = Object.freeze(["info", "warning", "urgent", "critical"]);
157
+ var SEVERITY_RANK = Object.freeze({ info: 0, warning: 1, urgent: 2, critical: 3 });
158
+
159
+ var ROLE_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
160
+ var KIND_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
161
+
162
+ var MAX_SUBJECT_LEN = 200;
163
+ var MAX_BODY_LEN = 8000;
164
+ var MAX_PAYLOAD_BYTES = 16384;
165
+ var MAX_SOURCE_LEN = 200;
166
+ var MAX_BULK_IDS = 200;
167
+
168
+ var DEFAULT_LIMIT = 50;
169
+ var MAX_LIMIT = 500;
170
+
171
+ var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
172
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
173
+
174
+ var CURSOR_ORDER_KEY = Object.freeze(["created_at", "id"]);
175
+
176
+ // ---- monotonic clock ----------------------------------------------------
177
+ //
178
+ // Two enqueues landing in the same millisecond on a fast machine would
179
+ // otherwise tie on `created_at`. Tests that enqueue + read in a tight
180
+ // loop rely on a strict total order for the (created_at DESC, id DESC)
181
+ // cursor sort — bumping by 1ms on collision keeps the sort total
182
+ // without an extra tiebreaker column at the schema layer.
183
+ var _lastTs = 0;
184
+ function _now() {
185
+ var t = Date.now();
186
+ if (t <= _lastTs) { t = _lastTs + 1; }
187
+ _lastTs = t;
188
+ return t;
189
+ }
190
+
191
+ // ---- validators ---------------------------------------------------------
192
+
193
+ function _operatorId(s) {
194
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
195
+ catch (e) { throw new TypeError("operatorInbox: operator_id — " + (e && e.message || "invalid UUID")); }
196
+ }
197
+
198
+ function _messageId(s) {
199
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
200
+ catch (e) { throw new TypeError("operatorInbox: id — " + (e && e.message || "invalid UUID")); }
201
+ }
202
+
203
+ function _role(s, label) {
204
+ if (typeof s !== "string" || !ROLE_RE.test(s)) {
205
+ throw new TypeError("operatorInbox: " + (label || "role") +
206
+ " must be lowercase alnum / underscore / dash, no leading/trailing separator, 1..64 chars");
207
+ }
208
+ return s;
209
+ }
210
+
211
+ function _kind(s) {
212
+ if (typeof s !== "string" || !KIND_RE.test(s)) {
213
+ throw new TypeError("operatorInbox: kind must be lowercase alnum / underscore / dash, 1..64 chars");
214
+ }
215
+ return s;
216
+ }
217
+
218
+ function _severity(s) {
219
+ if (typeof s !== "string" || SEVERITIES.indexOf(s) < 0) {
220
+ throw new TypeError("operatorInbox: severity must be one of " + SEVERITIES.join(", "));
221
+ }
222
+ return s;
223
+ }
224
+
225
+ function _severityMin(s) {
226
+ if (s == null) return null;
227
+ if (typeof s !== "string" || SEVERITIES.indexOf(s) < 0) {
228
+ throw new TypeError("operatorInbox: severity_min must be one of " + SEVERITIES.join(", "));
229
+ }
230
+ return s;
231
+ }
232
+
233
+ function _subject(s) {
234
+ if (typeof s !== "string" || !s.length || s.length > MAX_SUBJECT_LEN) {
235
+ throw new TypeError("operatorInbox: subject must be a non-empty string <= " + MAX_SUBJECT_LEN + " chars");
236
+ }
237
+ if (CONTROL_BYTE_RE.test(s)) {
238
+ throw new TypeError("operatorInbox: subject must not contain control bytes");
239
+ }
240
+ return s;
241
+ }
242
+
243
+ function _body(s) {
244
+ if (typeof s !== "string" || !s.length || s.length > MAX_BODY_LEN) {
245
+ throw new TypeError("operatorInbox: body must be a non-empty string <= " + MAX_BODY_LEN + " chars");
246
+ }
247
+ if (CONTROL_BYTE_RE.test(s)) {
248
+ throw new TypeError("operatorInbox: body must not contain control bytes");
249
+ }
250
+ return s;
251
+ }
252
+
253
+ function _payload(p) {
254
+ if (p == null) return {};
255
+ if (typeof p !== "object" || Array.isArray(p)) {
256
+ throw new TypeError("operatorInbox: payload must be a plain object");
257
+ }
258
+ var json;
259
+ try { json = JSON.stringify(p); }
260
+ catch (_e) { throw new TypeError("operatorInbox: payload must be JSON-serializable"); }
261
+ if (json.length > MAX_PAYLOAD_BYTES) {
262
+ throw new TypeError("operatorInbox: payload JSON must be <= " + MAX_PAYLOAD_BYTES + " bytes");
263
+ }
264
+ return JSON.parse(json);
265
+ }
266
+
267
+ function _sourceEventId(s) {
268
+ if (s == null || s === "") return null;
269
+ if (typeof s !== "string" || s.length > MAX_SOURCE_LEN) {
270
+ throw new TypeError("operatorInbox: source_event_id must be a string <= " + MAX_SOURCE_LEN + " chars");
271
+ }
272
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
273
+ throw new TypeError("operatorInbox: source_event_id must not contain control bytes");
274
+ }
275
+ return s;
276
+ }
277
+
278
+ function _limit(n) {
279
+ if (n == null) return DEFAULT_LIMIT;
280
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
281
+ throw new TypeError("operatorInbox: limit must be an integer in [1, " + MAX_LIMIT + "]");
282
+ }
283
+ return n;
284
+ }
285
+
286
+ function _epochOpt(n, label) {
287
+ if (n == null) return null;
288
+ if (!Number.isInteger(n) || n < 0) {
289
+ throw new TypeError("operatorInbox: " + label + " must be a non-negative integer (ms epoch) or null");
290
+ }
291
+ return n;
292
+ }
293
+
294
+ function _positiveInt(n, label) {
295
+ if (!Number.isInteger(n) || n <= 0) {
296
+ throw new TypeError("operatorInbox: " + label + " must be a positive integer");
297
+ }
298
+ return n;
299
+ }
300
+
301
+ function _bool(v, label, def) {
302
+ if (v == null) return def;
303
+ if (typeof v !== "boolean") {
304
+ throw new TypeError("operatorInbox: " + label + " must be a boolean");
305
+ }
306
+ return v;
307
+ }
308
+
309
+ // ---- factory ------------------------------------------------------------
310
+
311
+ function create(opts) {
312
+ opts = opts || {};
313
+ var query = opts.query;
314
+ if (!query) {
315
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
316
+ }
317
+
318
+ // Closed allow-list of role slugs the application is willing to
319
+ // address. Enqueueing a role-broadcast for an unknown role would
320
+ // create a write that no operator ever reads — refuse at the
321
+ // boundary instead of producing the silent black hole.
322
+ var operatorRoles = null;
323
+ if (opts.operatorRoles != null) {
324
+ if (Array.isArray(opts.operatorRoles)) {
325
+ var seen = Object.create(null);
326
+ var canon = [];
327
+ for (var i = 0; i < opts.operatorRoles.length; i += 1) {
328
+ var r = _role(opts.operatorRoles[i], "operatorRoles[" + i + "]");
329
+ if (seen[r]) {
330
+ throw new TypeError("operatorInbox.create: operatorRoles[" + i + "] duplicates a prior entry");
331
+ }
332
+ seen[r] = true;
333
+ canon.push(r);
334
+ }
335
+ operatorRoles = canon;
336
+ } else if (typeof opts.operatorRoles === "object"
337
+ && typeof opts.operatorRoles.rolesForOperator === "function") {
338
+ // Live operatorRoles peer — used in production wiring where the
339
+ // role membership of a given operator is dynamic.
340
+ operatorRoles = opts.operatorRoles;
341
+ } else {
342
+ throw new TypeError("operatorInbox.create: operatorRoles must be a string[] of allowed roles" +
343
+ " OR an object exposing rolesForOperator(operator_id)");
344
+ }
345
+ }
346
+
347
+ // HMAC-tagged cursor secret. Production wiring MUST pass an
348
+ // operator-managed secret; the dev fallback is only safe locally
349
+ // (a forged cursor reveals nothing the operator's own admin
350
+ // console doesn't already expose to them).
351
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
352
+ if (process.env.NODE_ENV === "production") {
353
+ throw new Error("operatorInbox.create: opts.cursorSecret is required in production");
354
+ }
355
+ opts.cursorSecret = "operator-inbox-cursor-secret-dev-only";
356
+ }
357
+ var cursorSecret = opts.cursorSecret;
358
+
359
+ function _decodeCursor(cursor, label) {
360
+ if (cursor == null) return null;
361
+ if (typeof cursor !== "string") {
362
+ throw new TypeError("operatorInbox." + label + ": cursor must be an opaque string or null");
363
+ }
364
+ try {
365
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
366
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(CURSOR_ORDER_KEY)) {
367
+ throw new TypeError("operatorInbox." + label + ": cursor orderKey mismatch");
368
+ }
369
+ if (!Array.isArray(state.vals) || state.vals.length !== 2) {
370
+ throw new TypeError("operatorInbox." + label + ": cursor vals shape mismatch");
371
+ }
372
+ var createdAt = state.vals[0];
373
+ var rowId = state.vals[1];
374
+ if (!Number.isInteger(createdAt) || createdAt < 0) {
375
+ throw new TypeError("operatorInbox." + label + ": cursor created_at must be a non-negative integer");
376
+ }
377
+ if (typeof rowId !== "string" || !rowId.length) {
378
+ throw new TypeError("operatorInbox." + label + ": cursor id must be a non-empty string");
379
+ }
380
+ return { created_at: createdAt, id: rowId };
381
+ } catch (e) {
382
+ if (e instanceof TypeError) throw e;
383
+ throw new TypeError("operatorInbox." + label + ": cursor — " + (e && e.message || "malformed"));
384
+ }
385
+ }
386
+
387
+ function _encodeNext(rows, limit) {
388
+ if (rows.length < limit) return null;
389
+ var last = rows[rows.length - 1];
390
+ return _b().pagination.encodeCursor({
391
+ orderKey: CURSOR_ORDER_KEY,
392
+ vals: [last.created_at, last.id],
393
+ forward: true,
394
+ }, cursorSecret);
395
+ }
396
+
397
+ // Resolve the operator's currently-active roles. Two wiring modes:
398
+ // - opts.operatorRoles as a string[] of allowed-roles only —
399
+ // the application has no live membership; role-broadcasts are
400
+ // enqueueable but `inboxForOperator` returns operator-id-only
401
+ // until the peer is wired.
402
+ // - opts.operatorRoles as a peer exposing rolesForOperator —
403
+ // live membership; role-broadcast rows fold in.
404
+ async function _rolesForOperator(operatorId) {
405
+ if (operatorRoles == null) return [];
406
+ if (Array.isArray(operatorRoles)) return [];
407
+ var rows = await operatorRoles.rolesForOperator(operatorId);
408
+ if (!Array.isArray(rows)) return [];
409
+ var out = [];
410
+ var seen = Object.create(null);
411
+ for (var i = 0; i < rows.length; i += 1) {
412
+ var row = rows[i];
413
+ var slug = (row && typeof row === "object") ? row.role_slug : row;
414
+ if (typeof slug !== "string" || !ROLE_RE.test(slug)) continue;
415
+ if (seen[slug]) continue;
416
+ seen[slug] = true;
417
+ out.push(slug);
418
+ }
419
+ return out;
420
+ }
421
+
422
+ function _isAllowedRole(slug) {
423
+ if (operatorRoles == null) return true; // no allow-list configured
424
+ if (Array.isArray(operatorRoles)) return operatorRoles.indexOf(slug) >= 0;
425
+ return true; // live peer handles its own gating at enqueue time
426
+ }
427
+
428
+ function _decodeRow(row) {
429
+ if (!row) return null;
430
+ var payload;
431
+ try { payload = JSON.parse(row.payload_json); }
432
+ catch (_e) { payload = {}; }
433
+ return {
434
+ id: row.id,
435
+ operator_id: row.operator_id,
436
+ role: row.role,
437
+ kind: row.kind,
438
+ severity: row.severity,
439
+ subject: row.subject,
440
+ body: row.body,
441
+ payload: payload,
442
+ source_event_id: row.source_event_id,
443
+ read_at: row.read_at != null ? Number(row.read_at) : null,
444
+ archived_at: row.archived_at != null ? Number(row.archived_at) : null,
445
+ created_at: Number(row.created_at),
446
+ };
447
+ }
448
+
449
+ // ---- enqueueMessage ---------------------------------------------------
450
+
451
+ async function enqueueMessage(input) {
452
+ if (!input || typeof input !== "object") {
453
+ throw new TypeError("operatorInbox.enqueueMessage: input object required");
454
+ }
455
+ var hasOperator = input.operator_id != null;
456
+ var hasRole = input.role != null;
457
+ if (hasOperator === hasRole) {
458
+ throw new TypeError("operatorInbox.enqueueMessage: exactly one of operator_id / role is required");
459
+ }
460
+ var operatorId = hasOperator ? _operatorId(input.operator_id) : null;
461
+ var role = hasRole ? _role(input.role, "role") : null;
462
+ if (role && !_isAllowedRole(role)) {
463
+ throw new TypeError("operatorInbox.enqueueMessage: role " + JSON.stringify(role) +
464
+ " is not in the configured operatorRoles allow-list");
465
+ }
466
+ var kind = _kind(input.kind);
467
+ var severity = _severity(input.severity);
468
+ var subject = _subject(input.subject);
469
+ var body = _body(input.body);
470
+ var payload = _payload(input.payload);
471
+ var sourceEventId = _sourceEventId(input.source_event_id);
472
+
473
+ var id = _b().uuid.v7();
474
+ var ts = _now();
475
+ await query(
476
+ "INSERT INTO operator_inbox_messages " +
477
+ "(id, operator_id, role, kind, severity, subject, body, payload_json, " +
478
+ " source_event_id, read_at, archived_at, created_at) " +
479
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, NULL, NULL, ?10)",
480
+ [id, operatorId, role, kind, severity, subject, body,
481
+ JSON.stringify(payload), sourceEventId, ts],
482
+ );
483
+ var r = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
484
+ return _decodeRow(r.rows[0]);
485
+ }
486
+
487
+ // ---- inboxForOperator -------------------------------------------------
488
+
489
+ async function inboxForOperator(input) {
490
+ if (!input || typeof input !== "object") {
491
+ throw new TypeError("operatorInbox.inboxForOperator: input object required");
492
+ }
493
+ var operatorId = _operatorId(input.operator_id);
494
+ var severityMin = _severityMin(input.severity_min);
495
+ var unreadOnly = _bool(input.unread_only, "unread_only", false);
496
+ var includeArchived = _bool(input.include_archived, "include_archived", false);
497
+ var limit = _limit(input.limit);
498
+ var cursorState = _decodeCursor(input.cursor, "inboxForOperator");
499
+
500
+ var roles = await _rolesForOperator(operatorId);
501
+
502
+ // Build WHERE clauses dynamically. Role placeholders are inlined
503
+ // as `?N` parameters (never string-concatenated values) so the
504
+ // SQL is parameter-safe regardless of the role allow-list shape.
505
+ var params = [];
506
+ var idx = 1;
507
+ var pushP = function (v) { params.push(v); var k = idx; idx += 1; return "?" + k; };
508
+
509
+ var opPlace = pushP(operatorId);
510
+
511
+ var addressClauses = ["operator_id = " + opPlace];
512
+ if (roles.length > 0) {
513
+ var placeholders = [];
514
+ for (var ri = 0; ri < roles.length; ri += 1) placeholders.push(pushP(roles[ri]));
515
+ addressClauses.push("role IN (" + placeholders.join(", ") + ")");
516
+ }
517
+
518
+ var where = "(" + addressClauses.join(" OR ") + ")";
519
+
520
+ if (!includeArchived) where += " AND archived_at IS NULL";
521
+ if (unreadOnly) where += " AND read_at IS NULL";
522
+
523
+ if (severityMin) {
524
+ var minRank = SEVERITY_RANK[severityMin];
525
+ var allowed = [];
526
+ for (var si = 0; si < SEVERITIES.length; si += 1) {
527
+ if (SEVERITY_RANK[SEVERITIES[si]] >= minRank) {
528
+ allowed.push(pushP(SEVERITIES[si]));
529
+ }
530
+ }
531
+ where += " AND severity IN (" + allowed.join(", ") + ")";
532
+ }
533
+
534
+ if (cursorState) {
535
+ // Forward cursor over (created_at DESC, id DESC). Predicate:
536
+ // created_at < cursor.created_at
537
+ // OR (created_at = cursor.created_at AND id < cursor.id)
538
+ var caP = pushP(cursorState.created_at);
539
+ var idP = pushP(cursorState.id);
540
+ where += " AND (created_at < " + caP +
541
+ " OR (created_at = " + caP + " AND id < " + idP + "))";
542
+ }
543
+
544
+ var limitPlace = pushP(limit);
545
+
546
+ var sql = "SELECT * FROM operator_inbox_messages WHERE " + where +
547
+ " ORDER BY created_at DESC, id DESC LIMIT " + limitPlace;
548
+ var r = await query(sql, params);
549
+ var rows = [];
550
+ for (var ki = 0; ki < r.rows.length; ki += 1) rows.push(_decodeRow(r.rows[ki]));
551
+ var nextCursor = rows.length === limit
552
+ ? _b().pagination.encodeCursor({
553
+ orderKey: CURSOR_ORDER_KEY,
554
+ vals: [rows[rows.length - 1].created_at, rows[rows.length - 1].id],
555
+ forward: true,
556
+ }, cursorSecret)
557
+ : null;
558
+ return { rows: rows, next_cursor: nextCursor };
559
+ }
560
+ // Mark the unused helper as used so eslint stays quiet. `_encodeNext`
561
+ // is the symmetric encoder kept for parity with knowledge-base —
562
+ // the inline version above is identical.
563
+ void _encodeNext;
564
+
565
+ // ---- addressability gate ----------------------------------------------
566
+ //
567
+ // A message is addressable to operator O iff either:
568
+ // - row.operator_id === O, OR
569
+ // - row.role !== null AND O currently carries role row.role.
570
+ //
571
+ // markRead / markUnread / archiveMessage all gate on this so an
572
+ // operator can't mutate another operator's id-addressed inbox row.
573
+ async function _addressableRow(messageId, operatorId, roles) {
574
+ var r = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [messageId]);
575
+ if (!r.rows.length) return { row: null, reason: "not_found" };
576
+ var row = r.rows[0];
577
+ if (row.operator_id != null && row.operator_id === operatorId) return { row: row, reason: null };
578
+ if (row.role != null) {
579
+ for (var i = 0; i < roles.length; i += 1) {
580
+ if (roles[i] === row.role) return { row: row, reason: null };
581
+ }
582
+ }
583
+ return { row: row, reason: "not_addressable" };
584
+ }
585
+
586
+ // ---- markRead ---------------------------------------------------------
587
+
588
+ async function markRead(input) {
589
+ if (!input || typeof input !== "object") {
590
+ throw new TypeError("operatorInbox.markRead: input object required");
591
+ }
592
+ var id = _messageId(input.id);
593
+ var operatorId = _operatorId(input.operator_id);
594
+ var roles = await _rolesForOperator(operatorId);
595
+
596
+ var gated = await _addressableRow(id, operatorId, roles);
597
+ if (gated.reason === "not_found") {
598
+ var miss = new Error("operatorInbox.markRead: message not found");
599
+ miss.code = "INBOX_MESSAGE_NOT_FOUND";
600
+ throw miss;
601
+ }
602
+ if (gated.reason === "not_addressable") {
603
+ var nope = new Error("operatorInbox.markRead: message not addressable to this operator");
604
+ nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
605
+ throw nope;
606
+ }
607
+ if (gated.row.read_at != null) {
608
+ // Idempotent — already read. Decode + return the current row.
609
+ return _decodeRow(gated.row);
610
+ }
611
+ var ts = _now();
612
+ await query(
613
+ "UPDATE operator_inbox_messages SET read_at = ?1 WHERE id = ?2 AND read_at IS NULL",
614
+ [ts, id],
615
+ );
616
+ var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
617
+ return _decodeRow(fresh.rows[0]);
618
+ }
619
+
620
+ // ---- markUnread -------------------------------------------------------
621
+
622
+ async function markUnread(input) {
623
+ if (!input || typeof input !== "object") {
624
+ throw new TypeError("operatorInbox.markUnread: input object required");
625
+ }
626
+ var id = _messageId(input.id);
627
+ var operatorId = _operatorId(input.operator_id);
628
+ var roles = await _rolesForOperator(operatorId);
629
+
630
+ var gated = await _addressableRow(id, operatorId, roles);
631
+ if (gated.reason === "not_found") {
632
+ var miss = new Error("operatorInbox.markUnread: message not found");
633
+ miss.code = "INBOX_MESSAGE_NOT_FOUND";
634
+ throw miss;
635
+ }
636
+ if (gated.reason === "not_addressable") {
637
+ var nope = new Error("operatorInbox.markUnread: message not addressable to this operator");
638
+ nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
639
+ throw nope;
640
+ }
641
+ if (gated.row.read_at == null) return _decodeRow(gated.row); // already unread
642
+ await query(
643
+ "UPDATE operator_inbox_messages SET read_at = NULL WHERE id = ?1",
644
+ [id],
645
+ );
646
+ var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
647
+ return _decodeRow(fresh.rows[0]);
648
+ }
649
+
650
+ // ---- archiveMessage ---------------------------------------------------
651
+
652
+ async function archiveMessage(input) {
653
+ if (!input || typeof input !== "object") {
654
+ throw new TypeError("operatorInbox.archiveMessage: input object required");
655
+ }
656
+ var id = _messageId(input.id);
657
+ var operatorId = _operatorId(input.operator_id);
658
+ var roles = await _rolesForOperator(operatorId);
659
+
660
+ var gated = await _addressableRow(id, operatorId, roles);
661
+ if (gated.reason === "not_found") {
662
+ var miss = new Error("operatorInbox.archiveMessage: message not found");
663
+ miss.code = "INBOX_MESSAGE_NOT_FOUND";
664
+ throw miss;
665
+ }
666
+ if (gated.reason === "not_addressable") {
667
+ var nope = new Error("operatorInbox.archiveMessage: message not addressable to this operator");
668
+ nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
669
+ throw nope;
670
+ }
671
+ if (gated.row.archived_at != null) return _decodeRow(gated.row); // idempotent
672
+ var ts = _now();
673
+ await query(
674
+ "UPDATE operator_inbox_messages SET archived_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
675
+ [ts, id],
676
+ );
677
+ var fresh = await query("SELECT * FROM operator_inbox_messages WHERE id = ?1", [id]);
678
+ return _decodeRow(fresh.rows[0]);
679
+ }
680
+
681
+ // ---- bulkArchive ------------------------------------------------------
682
+
683
+ async function bulkArchive(input) {
684
+ if (!input || typeof input !== "object") {
685
+ throw new TypeError("operatorInbox.bulkArchive: input object required");
686
+ }
687
+ var operatorId = _operatorId(input.operator_id);
688
+ if (!Array.isArray(input.ids) || input.ids.length === 0) {
689
+ throw new TypeError("operatorInbox.bulkArchive: ids must be a non-empty array");
690
+ }
691
+ if (input.ids.length > MAX_BULK_IDS) {
692
+ throw new TypeError("operatorInbox.bulkArchive: ids must contain <= " + MAX_BULK_IDS + " entries");
693
+ }
694
+ var ids = [];
695
+ var seen = Object.create(null);
696
+ for (var i = 0; i < input.ids.length; i += 1) {
697
+ var id = _messageId(input.ids[i]);
698
+ if (seen[id]) {
699
+ throw new TypeError("operatorInbox.bulkArchive: ids[" + i + "] duplicates a prior entry");
700
+ }
701
+ seen[id] = true;
702
+ ids.push(id);
703
+ }
704
+
705
+ var roles = await _rolesForOperator(operatorId);
706
+
707
+ // Pre-flight every id — if any is unaddressable, refuse the
708
+ // whole batch so the caller can re-try without a partial-write
709
+ // surprise.
710
+ for (var k = 0; k < ids.length; k += 1) {
711
+ var gated = await _addressableRow(ids[k], operatorId, roles);
712
+ if (gated.reason === "not_found") {
713
+ var miss = new Error("operatorInbox.bulkArchive: ids[" + k + "] not found");
714
+ miss.code = "INBOX_MESSAGE_NOT_FOUND";
715
+ throw miss;
716
+ }
717
+ if (gated.reason === "not_addressable") {
718
+ var nope = new Error("operatorInbox.bulkArchive: ids[" + k + "] not addressable to this operator");
719
+ nope.code = "INBOX_MESSAGE_NOT_ADDRESSABLE";
720
+ throw nope;
721
+ }
722
+ }
723
+
724
+ var ts = _now();
725
+ var archived = 0;
726
+ for (var m = 0; m < ids.length; m += 1) {
727
+ var u = await query(
728
+ "UPDATE operator_inbox_messages SET archived_at = ?1 " +
729
+ "WHERE id = ?2 AND archived_at IS NULL",
730
+ [ts, ids[m]],
731
+ );
732
+ archived += Number(u.rowCount || 0);
733
+ }
734
+ return { archived_count: archived };
735
+ }
736
+
737
+ // ---- unreadCount ------------------------------------------------------
738
+
739
+ async function unreadCount(input) {
740
+ if (!input || typeof input !== "object") {
741
+ throw new TypeError("operatorInbox.unreadCount: input object required");
742
+ }
743
+ var operatorId = _operatorId(input.operator_id);
744
+ var severityMin = _severityMin(input.severity_min);
745
+ var roles = await _rolesForOperator(operatorId);
746
+
747
+ var params = [];
748
+ var idx = 1;
749
+ var pushP = function (v) { params.push(v); var k = idx; idx += 1; return "?" + k; };
750
+ var opPlace = pushP(operatorId);
751
+
752
+ var addressClauses = ["operator_id = " + opPlace];
753
+ if (roles.length > 0) {
754
+ var placeholders = [];
755
+ for (var ri = 0; ri < roles.length; ri += 1) placeholders.push(pushP(roles[ri]));
756
+ addressClauses.push("role IN (" + placeholders.join(", ") + ")");
757
+ }
758
+ var where = "(" + addressClauses.join(" OR ") +
759
+ ") AND read_at IS NULL AND archived_at IS NULL";
760
+
761
+ if (severityMin) {
762
+ var minRank = SEVERITY_RANK[severityMin];
763
+ var allowed = [];
764
+ for (var si = 0; si < SEVERITIES.length; si += 1) {
765
+ if (SEVERITY_RANK[SEVERITIES[si]] >= minRank) {
766
+ allowed.push(pushP(SEVERITIES[si]));
767
+ }
768
+ }
769
+ where += " AND severity IN (" + allowed.join(", ") + ")";
770
+ }
771
+
772
+ var sql = "SELECT COUNT(*) AS c FROM operator_inbox_messages WHERE " + where;
773
+ var r = await query(sql, params);
774
+ var row = r.rows[0] || {};
775
+ return Number(row.c || row.COUNT || 0);
776
+ }
777
+
778
+ // ---- cleanupOlderThan -------------------------------------------------
779
+
780
+ async function cleanupOlderThan(input) {
781
+ if (!input || typeof input !== "object") {
782
+ throw new TypeError("operatorInbox.cleanupOlderThan: input object required");
783
+ }
784
+ var ageMs = _positiveInt(input.age_ms, "age_ms");
785
+ var now;
786
+ if (input.now != null) {
787
+ now = _epochOpt(input.now, "now");
788
+ } else {
789
+ now = _now();
790
+ }
791
+ var cutoff = now - ageMs;
792
+ var r = await query(
793
+ "DELETE FROM operator_inbox_messages WHERE created_at < ?1",
794
+ [cutoff],
795
+ );
796
+ return { deleted_count: Number(r.rowCount || 0), cutoff: cutoff };
797
+ }
798
+
799
+ // ---- metricsForKind ---------------------------------------------------
800
+
801
+ async function metricsForKind(input) {
802
+ if (!input || typeof input !== "object") {
803
+ throw new TypeError("operatorInbox.metricsForKind: input object required");
804
+ }
805
+ var kind = _kind(input.kind);
806
+ var from = _epochOpt(input.from, "from");
807
+ var to = _epochOpt(input.to, "to");
808
+ if (from != null && to != null && from > to) {
809
+ throw new TypeError("operatorInbox.metricsForKind: from must be <= to");
810
+ }
811
+
812
+ var sql = "SELECT severity, read_at, archived_at, created_at FROM operator_inbox_messages WHERE kind = ?1";
813
+ var params = [kind];
814
+ var idx = 2;
815
+ if (from != null) { sql += " AND created_at >= ?" + idx; params.push(from); idx += 1; }
816
+ if (to != null) { sql += " AND created_at < ?" + idx; params.push(to); idx += 1; }
817
+
818
+ var r = await query(sql, params);
819
+
820
+ var bySeverity = { info: 0, warning: 0, urgent: 0, critical: 0 };
821
+ var byState = { unread: 0, read: 0, archived: 0 };
822
+ var readLatencies = [];
823
+
824
+ for (var i = 0; i < r.rows.length; i += 1) {
825
+ var row = r.rows[i];
826
+ if (bySeverity[row.severity] != null) bySeverity[row.severity] += 1;
827
+ if (row.archived_at != null) {
828
+ byState.archived += 1;
829
+ } else if (row.read_at != null) {
830
+ byState.read += 1;
831
+ readLatencies.push(Number(row.read_at) - Number(row.created_at));
832
+ } else {
833
+ byState.unread += 1;
834
+ }
835
+ }
836
+
837
+ var medianReadLatencyMs = null;
838
+ if (readLatencies.length > 0) {
839
+ readLatencies.sort(function (a, b) { return a - b; });
840
+ var mid = readLatencies.length >> 1;
841
+ medianReadLatencyMs = readLatencies.length % 2 === 1
842
+ ? readLatencies[mid]
843
+ : Math.round((readLatencies[mid - 1] + readLatencies[mid]) / 2);
844
+ }
845
+
846
+ return {
847
+ kind: kind,
848
+ from: from,
849
+ to: to,
850
+ total: r.rows.length,
851
+ by_severity: bySeverity,
852
+ by_state: byState,
853
+ median_read_latency_ms: medianReadLatencyMs,
854
+ };
855
+ }
856
+
857
+ return {
858
+ SEVERITIES: SEVERITIES.slice(),
859
+ SEVERITY_RANK: Object.assign({}, SEVERITY_RANK),
860
+ MAX_SUBJECT_LEN: MAX_SUBJECT_LEN,
861
+ MAX_BODY_LEN: MAX_BODY_LEN,
862
+ MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
863
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
864
+ MAX_LIMIT: MAX_LIMIT,
865
+ MAX_BULK_IDS: MAX_BULK_IDS,
866
+
867
+ enqueueMessage: enqueueMessage,
868
+ inboxForOperator: inboxForOperator,
869
+ markRead: markRead,
870
+ markUnread: markUnread,
871
+ archiveMessage: archiveMessage,
872
+ bulkArchive: bulkArchive,
873
+ unreadCount: unreadCount,
874
+ cleanupOlderThan: cleanupOlderThan,
875
+ metricsForKind: metricsForKind,
876
+ };
877
+ }
878
+
879
+ module.exports = {
880
+ create: create,
881
+ SEVERITIES: SEVERITIES,
882
+ SEVERITY_RANK: SEVERITY_RANK,
883
+ MAX_SUBJECT_LEN: MAX_SUBJECT_LEN,
884
+ MAX_BODY_LEN: MAX_BODY_LEN,
885
+ MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
886
+ DEFAULT_LIMIT: DEFAULT_LIMIT,
887
+ MAX_LIMIT: MAX_LIMIT,
888
+ MAX_BULK_IDS: MAX_BULK_IDS,
889
+ };