@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.
- package/CHANGELOG.md +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- 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
|
+
};
|