@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,942 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.operatorApprovals
|
|
4
|
+
* @title Operator approvals — multi-step approval workflows for
|
|
5
|
+
* high-risk operator actions
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Some operator actions are too risky to execute on the strength
|
|
9
|
+
* of a single operator's intent — large refunds (the customer
|
|
10
|
+
* trust + chargeback-window exposure exceeds the single agent's
|
|
11
|
+
* blast radius), bulk catalog edits (a typo in a bulk-price-cut
|
|
12
|
+
* propagates to every variant before anyone notices), payment-
|
|
13
|
+
* method changes (the operator's own banking destination shifts
|
|
14
|
+
* under the chargeback queue), vendor payout overrides (real
|
|
15
|
+
* money to a third party). The `operatorApprovals` primitive
|
|
16
|
+
* models these as multi-step workflows: the requesting operator
|
|
17
|
+
* submits the action, named approvers cast votes, and the
|
|
18
|
+
* application only executes once the workflow's threshold is met.
|
|
19
|
+
*
|
|
20
|
+
* A workflow is operator-authored once via `defineWorkflow` and
|
|
21
|
+
* carries the gating policy: how many approvers are required to
|
|
22
|
+
* pass, whether approvers must hold a named capability token
|
|
23
|
+
* from the `operatorRoles` allow-list, how long pending requests
|
|
24
|
+
* wait before escalation, and an optional `auto_approve_threshold`
|
|
25
|
+
* that lets the application short-circuit small-payload requests
|
|
26
|
+
* without a vote (e.g. refunds under $100 still record an audit
|
|
27
|
+
* row but skip the approval step).
|
|
28
|
+
*
|
|
29
|
+
* The FSM per request is closed:
|
|
30
|
+
*
|
|
31
|
+
* pending -> approved (votes_for >= required_approvers)
|
|
32
|
+
* pending -> rejected (any single `reject` vote)
|
|
33
|
+
* pending -> escalated (operator records the escalation)
|
|
34
|
+
* pending -> cancelled (requester withdraws before resolve)
|
|
35
|
+
* approved -> executed (markExecuted records the outcome)
|
|
36
|
+
* approved -> cancelled (operator can still cancel after
|
|
37
|
+
* approval but before execution)
|
|
38
|
+
* escalated -> approved (vote-driven; the escalation flag is
|
|
39
|
+
* metadata, not a terminal state)
|
|
40
|
+
* escalated -> rejected
|
|
41
|
+
* escalated -> cancelled
|
|
42
|
+
*
|
|
43
|
+
* Multi-approver semantics: ANY single `reject` vote flips the
|
|
44
|
+
* request to `rejected`, which matches "any one approver can veto
|
|
45
|
+
* a money move" — the bar an operator wants for the pre-execution
|
|
46
|
+
* gate. Approvers can leave an `abstain` vote that counts neither
|
|
47
|
+
* for nor against; abstentions are recorded so an auditor can see
|
|
48
|
+
* who saw the request and declined to weigh in.
|
|
49
|
+
*
|
|
50
|
+
* Surface:
|
|
51
|
+
*
|
|
52
|
+
* - defineWorkflow({ slug, action_kind, required_approvers,
|
|
53
|
+
* required_capability?, escalation_after_hours?,
|
|
54
|
+
* auto_approve_threshold? })
|
|
55
|
+
* Create / update the workflow definition. Re-defining the
|
|
56
|
+
* same slug refreshes the policy in place (the workflow
|
|
57
|
+
* identity is stable — the rules are operator-tunable).
|
|
58
|
+
*
|
|
59
|
+
* - requestApproval({ workflow_slug, requested_by, payload,
|
|
60
|
+
* justification })
|
|
61
|
+
* Submit a new approval request. `payload` is the
|
|
62
|
+
* operator-supplied structured description of the action
|
|
63
|
+
* (refund amount, vendor id, catalog patch); the primitive
|
|
64
|
+
* does NOT interpret it but does JSON-validate +
|
|
65
|
+
* size-clamp. Returns the persisted row at `status:
|
|
66
|
+
* "pending"`. Refused if the workflow is archived.
|
|
67
|
+
*
|
|
68
|
+
* - castVote({ request_id, approver_id, decision, comment? })
|
|
69
|
+
* Record a vote. `decision` is one of approve / reject /
|
|
70
|
+
* abstain. UNIQUE(request_id, approver_id) holds at the
|
|
71
|
+
* schema level; the primitive surfaces the collision as a
|
|
72
|
+
* friendly refusal. When `operatorRoles` is wired and the
|
|
73
|
+
* workflow declares a `required_capability`, the approver's
|
|
74
|
+
* active roles are checked through `hasPermission` and the
|
|
75
|
+
* vote is refused if the approver doesn't carry the
|
|
76
|
+
* capability. Crossing the `required_approvers` threshold
|
|
77
|
+
* flips the request to `approved`; a `reject` vote flips it
|
|
78
|
+
* to `rejected` immediately.
|
|
79
|
+
*
|
|
80
|
+
* - recordEscalation({ request_id, escalated_to, reason })
|
|
81
|
+
* Stamp the escalation columns on a pending request. The
|
|
82
|
+
* primitive does NOT itself enforce the
|
|
83
|
+
* `escalation_after_hours` deadline — the application sweeps
|
|
84
|
+
* pending requests (or wires a scheduler) and calls this
|
|
85
|
+
* when the deadline passes. The `escalated_to` field is the
|
|
86
|
+
* operator-id (or role slug) of the escalation target; the
|
|
87
|
+
* primitive does NOT validate it against the operator
|
|
88
|
+
* registry, leaving that to the wiring layer.
|
|
89
|
+
*
|
|
90
|
+
* - markExecuted({ request_id, executed_by, result })
|
|
91
|
+
* Final state transition: an approved request has been
|
|
92
|
+
* executed by the application. `result` is JSON-validated +
|
|
93
|
+
* size-clamped. Refused if the request is not at
|
|
94
|
+
* `approved`.
|
|
95
|
+
*
|
|
96
|
+
* - cancelRequest({ request_id, reason })
|
|
97
|
+
* Operator-initiated withdrawal. Allowed from pending /
|
|
98
|
+
* approved / escalated; refused from executed / rejected
|
|
99
|
+
* (the action is already done one way or the other) and
|
|
100
|
+
* from already-cancelled.
|
|
101
|
+
*
|
|
102
|
+
* - getRequest(request_id)
|
|
103
|
+
* Single-row read with denormalized vote tally + nested
|
|
104
|
+
* votes array.
|
|
105
|
+
*
|
|
106
|
+
* - pendingForApprover({ approver_id, workflow_slug? })
|
|
107
|
+
* Pending (or escalated) requests the named approver has
|
|
108
|
+
* not yet voted on. When `workflow_slug` is supplied the
|
|
109
|
+
* filter narrows to a single workflow. Used by the operator
|
|
110
|
+
* console "approvals awaiting you" badge.
|
|
111
|
+
*
|
|
112
|
+
* - myRequests({ requester_id, status?, limit? })
|
|
113
|
+
* The requesting operator's own submission history.
|
|
114
|
+
*
|
|
115
|
+
* - metricsForWorkflow({ slug, from, to })
|
|
116
|
+
* Aggregate counters over an [from, to) epoch-ms window —
|
|
117
|
+
* total / by_status / median_time_to_resolve_ms /
|
|
118
|
+
* auto_approved / escalated.
|
|
119
|
+
*
|
|
120
|
+
* Composes ONLY blamejs (zero npm deps):
|
|
121
|
+
* - `b.uuid.v7` — request + vote row PKs
|
|
122
|
+
* - `shop.operatorRoles` — optional peer for the
|
|
123
|
+
* required_capability gate +
|
|
124
|
+
* audit trail
|
|
125
|
+
* - `shop.operatorAuditLog` — optional peer; every
|
|
126
|
+
* request / vote / execute /
|
|
127
|
+
* cancel / escalate records a row
|
|
128
|
+
* via the duck-typed `.record(...)`
|
|
129
|
+
* surface
|
|
130
|
+
* - `shop.operatorInbox` — optional peer; requestApproval
|
|
131
|
+
* enqueues a role-broadcast
|
|
132
|
+
* message addressed to the
|
|
133
|
+
* workflow's `required_capability`
|
|
134
|
+
* when wired
|
|
135
|
+
*
|
|
136
|
+
* Monotonic clock: a per-factory monotonic timestamp ensures that
|
|
137
|
+
* two votes / state-transitions landing in the same wall-clock
|
|
138
|
+
* millisecond carry strictly-increasing `occurred_at` /
|
|
139
|
+
* `updated_at` values. The audit ordering is total even on fast
|
|
140
|
+
* runners that collapse `Date.now()` inside one tick.
|
|
141
|
+
*
|
|
142
|
+
* Storage: `migrations-d1/0192_operator_approvals.sql` — three
|
|
143
|
+
* tables (`approval_workflows` + `approval_requests` +
|
|
144
|
+
* `approval_votes`) with their indexes.
|
|
145
|
+
*
|
|
146
|
+
* @primitive operatorApprovals
|
|
147
|
+
* @related operatorRoles, operatorAuditLog, operatorInbox, b.uuid
|
|
148
|
+
*/
|
|
149
|
+
|
|
150
|
+
// ---- constants ----------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
var MAX_SLUG_LEN = 80;
|
|
153
|
+
var MAX_ACTION_KIND_LEN = 80;
|
|
154
|
+
var MAX_OPERATOR_ID_LEN = 128;
|
|
155
|
+
var MAX_REASON_LEN = 500;
|
|
156
|
+
var MAX_COMMENT_LEN = 2000;
|
|
157
|
+
var MAX_JUSTIFICATION_LEN = 4000;
|
|
158
|
+
var MAX_PAYLOAD_BYTES = 64 * 1024;
|
|
159
|
+
var MAX_RESULT_BYTES = 64 * 1024;
|
|
160
|
+
var MAX_REQUIRED_APPROVERS = 32;
|
|
161
|
+
var MAX_LIST_LIMIT = 500;
|
|
162
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
163
|
+
var MAX_HOURS = 24 * 365; // one-year cap
|
|
164
|
+
|
|
165
|
+
var SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$/;
|
|
166
|
+
var ACTION_KIND_RE = /^[a-z][a-z0-9_.\-]{0,79}$/;
|
|
167
|
+
var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
|
|
168
|
+
|
|
169
|
+
var DECISIONS = Object.freeze(["approve", "reject", "abstain"]);
|
|
170
|
+
var STATUSES = Object.freeze(["pending", "approved", "rejected",
|
|
171
|
+
"executed", "cancelled", "escalated"]);
|
|
172
|
+
|
|
173
|
+
var bShop;
|
|
174
|
+
function _b() {
|
|
175
|
+
if (!bShop) bShop = require("./index");
|
|
176
|
+
return bShop.framework;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ---- validators ---------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
function _slug(s, label) {
|
|
182
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
183
|
+
throw new TypeError("operatorApprovals: " + (label || "slug") +
|
|
184
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (<= " + MAX_SLUG_LEN + " chars)");
|
|
185
|
+
}
|
|
186
|
+
return s;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function _actionKind(s) {
|
|
190
|
+
if (typeof s !== "string" || !ACTION_KIND_RE.test(s)) {
|
|
191
|
+
throw new TypeError("operatorApprovals: action_kind must be lowercase " +
|
|
192
|
+
"alnum / underscore / dot / dash, 1.." + MAX_ACTION_KIND_LEN + " chars");
|
|
193
|
+
}
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function _operatorId(s, label) {
|
|
198
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_OPERATOR_ID_LEN) {
|
|
199
|
+
throw new TypeError("operatorApprovals: " + label +
|
|
200
|
+
" must be a non-empty string (<= " + MAX_OPERATOR_ID_LEN + " chars)");
|
|
201
|
+
}
|
|
202
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
203
|
+
throw new TypeError("operatorApprovals: " + label + " must not contain control bytes");
|
|
204
|
+
}
|
|
205
|
+
return s;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _reason(s) {
|
|
209
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_REASON_LEN) {
|
|
210
|
+
throw new TypeError("operatorApprovals: reason must be a non-empty string <= " +
|
|
211
|
+
MAX_REASON_LEN + " chars");
|
|
212
|
+
}
|
|
213
|
+
if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
|
|
214
|
+
throw new TypeError("operatorApprovals: reason must not contain control bytes (except whitespace)");
|
|
215
|
+
}
|
|
216
|
+
return s;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function _justification(s) {
|
|
220
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_JUSTIFICATION_LEN) {
|
|
221
|
+
throw new TypeError("operatorApprovals: justification must be a non-empty string <= " +
|
|
222
|
+
MAX_JUSTIFICATION_LEN + " chars");
|
|
223
|
+
}
|
|
224
|
+
if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
|
|
225
|
+
throw new TypeError("operatorApprovals: justification must not contain control bytes (except whitespace)");
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function _comment(s) {
|
|
231
|
+
if (s == null) return null;
|
|
232
|
+
if (typeof s !== "string" || s.length > MAX_COMMENT_LEN) {
|
|
233
|
+
throw new TypeError("operatorApprovals: comment must be a string <= " +
|
|
234
|
+
MAX_COMMENT_LEN + " chars");
|
|
235
|
+
}
|
|
236
|
+
if (CONTROL_BYTE_RE.test(s.replace(/[\t\r\n]/g, ""))) {
|
|
237
|
+
throw new TypeError("operatorApprovals: comment must not contain control bytes (except whitespace)");
|
|
238
|
+
}
|
|
239
|
+
return s;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _decision(s) {
|
|
243
|
+
if (typeof s !== "string" || DECISIONS.indexOf(s) < 0) {
|
|
244
|
+
throw new TypeError("operatorApprovals: decision must be one of " + DECISIONS.join(", "));
|
|
245
|
+
}
|
|
246
|
+
return s;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function _status(s, label) {
|
|
250
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) < 0) {
|
|
251
|
+
throw new TypeError("operatorApprovals: " + (label || "status") +
|
|
252
|
+
" must be one of " + STATUSES.join(", "));
|
|
253
|
+
}
|
|
254
|
+
return s;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _requiredApprovers(n) {
|
|
258
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_REQUIRED_APPROVERS) {
|
|
259
|
+
throw new TypeError("operatorApprovals: required_approvers must be an integer in [1, " +
|
|
260
|
+
MAX_REQUIRED_APPROVERS + "]");
|
|
261
|
+
}
|
|
262
|
+
return n;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function _hours(n, label) {
|
|
266
|
+
if (n == null) return null;
|
|
267
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_HOURS) {
|
|
268
|
+
throw new TypeError("operatorApprovals: " + label +
|
|
269
|
+
" must be a positive integer (hours) <= " + MAX_HOURS);
|
|
270
|
+
}
|
|
271
|
+
return n;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _autoApprove(n) {
|
|
275
|
+
if (n == null) return null;
|
|
276
|
+
if (!Number.isInteger(n) || n < 1) {
|
|
277
|
+
throw new TypeError("operatorApprovals: auto_approve_threshold must be a positive integer or null");
|
|
278
|
+
}
|
|
279
|
+
return n;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function _epochMs(n, label) {
|
|
283
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
284
|
+
throw new TypeError("operatorApprovals: " + label +
|
|
285
|
+
" must be a non-negative integer (epoch ms)");
|
|
286
|
+
}
|
|
287
|
+
return n;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _limit(n) {
|
|
291
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
292
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
293
|
+
throw new TypeError("operatorApprovals: limit must be an integer in [1, " +
|
|
294
|
+
MAX_LIST_LIMIT + "]");
|
|
295
|
+
}
|
|
296
|
+
return n;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function _json(v, label, maxBytes) {
|
|
300
|
+
if (v == null) {
|
|
301
|
+
throw new TypeError("operatorApprovals: " + label + " must be a plain object");
|
|
302
|
+
}
|
|
303
|
+
if (typeof v !== "object" || Array.isArray(v)) {
|
|
304
|
+
throw new TypeError("operatorApprovals: " + label + " must be a plain object");
|
|
305
|
+
}
|
|
306
|
+
var json;
|
|
307
|
+
try { json = JSON.stringify(v); }
|
|
308
|
+
catch (_e) { throw new TypeError("operatorApprovals: " + label + " must be JSON-serializable"); }
|
|
309
|
+
if (json === undefined) {
|
|
310
|
+
throw new TypeError("operatorApprovals: " + label + " must be JSON-serializable");
|
|
311
|
+
}
|
|
312
|
+
if (json.length > maxBytes) {
|
|
313
|
+
throw new TypeError("operatorApprovals: " + label + " must serialize to <= " +
|
|
314
|
+
maxBytes + " bytes");
|
|
315
|
+
}
|
|
316
|
+
return json;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function _capability(s) {
|
|
320
|
+
if (s == null) return null;
|
|
321
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_ACTION_KIND_LEN) {
|
|
322
|
+
throw new TypeError("operatorApprovals: required_capability must be a non-empty string or null");
|
|
323
|
+
}
|
|
324
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
325
|
+
throw new TypeError("operatorApprovals: required_capability must not contain control bytes");
|
|
326
|
+
}
|
|
327
|
+
return s;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// ---- row hydration ------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
function _safeParseObject(s) {
|
|
333
|
+
if (s == null) return null;
|
|
334
|
+
try {
|
|
335
|
+
var parsed = JSON.parse(s);
|
|
336
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
337
|
+
return null;
|
|
338
|
+
} catch (_e) { return null; }
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function _hydrateWorkflow(r) {
|
|
342
|
+
if (!r) return null;
|
|
343
|
+
return {
|
|
344
|
+
slug: r.slug,
|
|
345
|
+
action_kind: r.action_kind,
|
|
346
|
+
required_approvers: Number(r.required_approvers),
|
|
347
|
+
required_capability: r.required_capability == null ? null : r.required_capability,
|
|
348
|
+
escalation_after_hours: r.escalation_after_hours == null ? null : Number(r.escalation_after_hours),
|
|
349
|
+
auto_approve_threshold: r.auto_approve_threshold == null ? null : Number(r.auto_approve_threshold),
|
|
350
|
+
archived_at: r.archived_at == null ? null : Number(r.archived_at),
|
|
351
|
+
created_at: Number(r.created_at),
|
|
352
|
+
updated_at: Number(r.updated_at),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function _hydrateRequest(r) {
|
|
357
|
+
if (!r) return null;
|
|
358
|
+
return {
|
|
359
|
+
id: r.id,
|
|
360
|
+
workflow_slug: r.workflow_slug,
|
|
361
|
+
requested_by: r.requested_by,
|
|
362
|
+
payload: _safeParseObject(r.payload_json) || {},
|
|
363
|
+
justification: r.justification,
|
|
364
|
+
status: r.status,
|
|
365
|
+
votes_for: Number(r.votes_for),
|
|
366
|
+
votes_against: Number(r.votes_against),
|
|
367
|
+
executed_by: r.executed_by == null ? null : r.executed_by,
|
|
368
|
+
executed_at: r.executed_at == null ? null : Number(r.executed_at),
|
|
369
|
+
result: _safeParseObject(r.result_json),
|
|
370
|
+
cancelled_at: r.cancelled_at == null ? null : Number(r.cancelled_at),
|
|
371
|
+
cancel_reason: r.cancel_reason == null ? null : r.cancel_reason,
|
|
372
|
+
escalated_to: r.escalated_to == null ? null : r.escalated_to,
|
|
373
|
+
escalated_at: r.escalated_at == null ? null : Number(r.escalated_at),
|
|
374
|
+
escalation_reason: r.escalation_reason == null ? null : r.escalation_reason,
|
|
375
|
+
created_at: Number(r.created_at),
|
|
376
|
+
updated_at: Number(r.updated_at),
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function _hydrateVote(r) {
|
|
381
|
+
if (!r) return null;
|
|
382
|
+
return {
|
|
383
|
+
id: r.id,
|
|
384
|
+
request_id: r.request_id,
|
|
385
|
+
approver_id: r.approver_id,
|
|
386
|
+
decision: r.decision,
|
|
387
|
+
comment: r.comment == null ? null : r.comment,
|
|
388
|
+
occurred_at: Number(r.occurred_at),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// ---- factory ------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
function create(opts) {
|
|
395
|
+
opts = opts || {};
|
|
396
|
+
var query = opts.query;
|
|
397
|
+
if (!query) {
|
|
398
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Optional peer — when wired, `castVote` consults
|
|
402
|
+
// `hasPermission({ operator_id, permission })` against the workflow's
|
|
403
|
+
// `required_capability`. Without it, capability is advisory metadata.
|
|
404
|
+
var operatorRoles = opts.operatorRoles || null;
|
|
405
|
+
if (operatorRoles != null) {
|
|
406
|
+
if (typeof operatorRoles !== "object" ||
|
|
407
|
+
typeof operatorRoles.hasPermission !== "function") {
|
|
408
|
+
throw new TypeError("operatorApprovals.create: operatorRoles must expose hasPermission(...)");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Optional peer — every state-changing call records an audit row
|
|
413
|
+
// via duck-typed `.record(...)` matching the operatorAuditLog
|
|
414
|
+
// surface (migration 0074).
|
|
415
|
+
var operatorAuditLog = opts.operatorAuditLog || null;
|
|
416
|
+
if (operatorAuditLog != null) {
|
|
417
|
+
if (typeof operatorAuditLog !== "object" ||
|
|
418
|
+
typeof operatorAuditLog.record !== "function") {
|
|
419
|
+
throw new TypeError("operatorApprovals.create: operatorAuditLog must expose record(...)");
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// Optional peer — `requestApproval` enqueues a role-broadcast
|
|
424
|
+
// inbox message addressed to the workflow's `required_capability`
|
|
425
|
+
// when this is wired.
|
|
426
|
+
var operatorInbox = opts.operatorInbox || null;
|
|
427
|
+
if (operatorInbox != null) {
|
|
428
|
+
if (typeof operatorInbox !== "object" ||
|
|
429
|
+
typeof operatorInbox.enqueueMessage !== "function") {
|
|
430
|
+
throw new TypeError("operatorApprovals.create: operatorInbox must expose enqueueMessage(...)");
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Per-factory monotonic clock. Fast platforms collapse `Date.now()`
|
|
435
|
+
// to identical readings inside one tick; the bump keeps the audit
|
|
436
|
+
// ordering deterministic — two votes / state transitions landing in
|
|
437
|
+
// the same millisecond still carry strictly-increasing timestamps.
|
|
438
|
+
var _lastTs = 0;
|
|
439
|
+
function _monotonicTs() {
|
|
440
|
+
var wall = Date.now();
|
|
441
|
+
if (wall > _lastTs) _lastTs = wall;
|
|
442
|
+
else _lastTs += 1;
|
|
443
|
+
return _lastTs;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---- audit helper --------------------------------------------------
|
|
447
|
+
|
|
448
|
+
async function _audit(action, actorId, resourceId, before, after) {
|
|
449
|
+
if (!operatorAuditLog) return;
|
|
450
|
+
try {
|
|
451
|
+
await operatorAuditLog.record({
|
|
452
|
+
actor_type: "operator",
|
|
453
|
+
actor_id: actorId,
|
|
454
|
+
action: action,
|
|
455
|
+
resource_kind: "approval_request",
|
|
456
|
+
resource_id: resourceId,
|
|
457
|
+
before: before,
|
|
458
|
+
after: after,
|
|
459
|
+
});
|
|
460
|
+
} catch (_e) {
|
|
461
|
+
// Audit failure does not roll back the state change — the
|
|
462
|
+
// primitive's contract is "best-effort chain to the peer". The
|
|
463
|
+
// peer's own internals carry the hard guarantees.
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---- defineWorkflow -----------------------------------------------
|
|
468
|
+
|
|
469
|
+
async function defineWorkflow(input) {
|
|
470
|
+
if (!input || typeof input !== "object") {
|
|
471
|
+
throw new TypeError("operatorApprovals.defineWorkflow: input object required");
|
|
472
|
+
}
|
|
473
|
+
var slug = _slug(input.slug);
|
|
474
|
+
var actionKind = _actionKind(input.action_kind);
|
|
475
|
+
var requiredApprovers = _requiredApprovers(input.required_approvers);
|
|
476
|
+
var requiredCapability = _capability(input.required_capability);
|
|
477
|
+
var escalationAfterHours = _hours(input.escalation_after_hours, "escalation_after_hours");
|
|
478
|
+
var autoApproveThreshold = _autoApprove(input.auto_approve_threshold);
|
|
479
|
+
|
|
480
|
+
var ts = _monotonicTs();
|
|
481
|
+
|
|
482
|
+
var existing = (await query(
|
|
483
|
+
"SELECT slug FROM approval_workflows WHERE slug = ?1 LIMIT 1",
|
|
484
|
+
[slug],
|
|
485
|
+
)).rows[0];
|
|
486
|
+
|
|
487
|
+
if (existing) {
|
|
488
|
+
// In-place policy refresh — the workflow identity is stable;
|
|
489
|
+
// operator-tunable rules ride the updated_at bump.
|
|
490
|
+
await query(
|
|
491
|
+
"UPDATE approval_workflows " +
|
|
492
|
+
"SET action_kind = ?1, required_approvers = ?2, required_capability = ?3, " +
|
|
493
|
+
"escalation_after_hours = ?4, auto_approve_threshold = ?5, updated_at = ?6 " +
|
|
494
|
+
"WHERE slug = ?7",
|
|
495
|
+
[actionKind, requiredApprovers, requiredCapability,
|
|
496
|
+
escalationAfterHours, autoApproveThreshold, ts, slug],
|
|
497
|
+
);
|
|
498
|
+
} else {
|
|
499
|
+
await query(
|
|
500
|
+
"INSERT INTO approval_workflows " +
|
|
501
|
+
"(slug, action_kind, required_approvers, required_capability, " +
|
|
502
|
+
" escalation_after_hours, auto_approve_threshold, archived_at, created_at, updated_at) " +
|
|
503
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
|
|
504
|
+
[slug, actionKind, requiredApprovers, requiredCapability,
|
|
505
|
+
escalationAfterHours, autoApproveThreshold, ts],
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return await _getWorkflow(slug);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function _getWorkflow(slug) {
|
|
513
|
+
var r = (await query(
|
|
514
|
+
"SELECT * FROM approval_workflows WHERE slug = ?1 LIMIT 1",
|
|
515
|
+
[slug],
|
|
516
|
+
)).rows[0];
|
|
517
|
+
return _hydrateWorkflow(r);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ---- requestApproval ----------------------------------------------
|
|
521
|
+
|
|
522
|
+
async function requestApproval(input) {
|
|
523
|
+
if (!input || typeof input !== "object") {
|
|
524
|
+
throw new TypeError("operatorApprovals.requestApproval: input object required");
|
|
525
|
+
}
|
|
526
|
+
var workflowSlug = _slug(input.workflow_slug, "workflow_slug");
|
|
527
|
+
var requestedBy = _operatorId(input.requested_by, "requested_by");
|
|
528
|
+
var payloadJson = _json(input.payload, "payload", MAX_PAYLOAD_BYTES);
|
|
529
|
+
var justification = _justification(input.justification);
|
|
530
|
+
|
|
531
|
+
var wf = await _getWorkflow(workflowSlug);
|
|
532
|
+
if (!wf) {
|
|
533
|
+
throw new TypeError("operatorApprovals.requestApproval: workflow " +
|
|
534
|
+
JSON.stringify(workflowSlug) + " not found");
|
|
535
|
+
}
|
|
536
|
+
if (wf.archived_at != null) {
|
|
537
|
+
throw new TypeError("operatorApprovals.requestApproval: workflow " +
|
|
538
|
+
JSON.stringify(workflowSlug) + " is archived - new requests are refused");
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
var id = _b().uuid.v7();
|
|
542
|
+
var ts = _monotonicTs();
|
|
543
|
+
await query(
|
|
544
|
+
"INSERT INTO approval_requests " +
|
|
545
|
+
"(id, workflow_slug, requested_by, payload_json, justification, status, " +
|
|
546
|
+
" votes_for, votes_against, executed_by, executed_at, result_json, " +
|
|
547
|
+
" cancelled_at, cancel_reason, escalated_to, escalated_at, escalation_reason, " +
|
|
548
|
+
" created_at, updated_at) " +
|
|
549
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'pending', 0, 0, NULL, NULL, NULL, " +
|
|
550
|
+
" NULL, NULL, NULL, NULL, NULL, ?6, ?6)",
|
|
551
|
+
[id, workflowSlug, requestedBy, payloadJson, justification, ts],
|
|
552
|
+
);
|
|
553
|
+
|
|
554
|
+
// Audit + inbox broadcast composition.
|
|
555
|
+
await _audit("approval.request", requestedBy, id, null,
|
|
556
|
+
{ workflow_slug: workflowSlug, justification: justification });
|
|
557
|
+
|
|
558
|
+
if (operatorInbox && wf.required_capability) {
|
|
559
|
+
try {
|
|
560
|
+
await operatorInbox.enqueueMessage({
|
|
561
|
+
role: wf.required_capability,
|
|
562
|
+
kind: "approval_request",
|
|
563
|
+
severity: "warning",
|
|
564
|
+
subject: "Approval requested: " + wf.action_kind,
|
|
565
|
+
body: justification,
|
|
566
|
+
payload: { request_id: id, workflow_slug: workflowSlug },
|
|
567
|
+
});
|
|
568
|
+
} catch (_e) {
|
|
569
|
+
// Inbox failure does not roll back — the request is still
|
|
570
|
+
// pending; the caller can re-broadcast.
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
return await getRequest(id);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ---- castVote -----------------------------------------------------
|
|
578
|
+
|
|
579
|
+
async function castVote(input) {
|
|
580
|
+
if (!input || typeof input !== "object") {
|
|
581
|
+
throw new TypeError("operatorApprovals.castVote: input object required");
|
|
582
|
+
}
|
|
583
|
+
var requestId = _operatorId(input.request_id, "request_id");
|
|
584
|
+
var approverId = _operatorId(input.approver_id, "approver_id");
|
|
585
|
+
var decision = _decision(input.decision);
|
|
586
|
+
var comment = _comment(input.comment);
|
|
587
|
+
|
|
588
|
+
var req = (await query(
|
|
589
|
+
"SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
|
|
590
|
+
[requestId],
|
|
591
|
+
)).rows[0];
|
|
592
|
+
if (!req) {
|
|
593
|
+
throw new TypeError("operatorApprovals.castVote: request " +
|
|
594
|
+
JSON.stringify(requestId) + " not found");
|
|
595
|
+
}
|
|
596
|
+
if (req.status !== "pending" && req.status !== "escalated") {
|
|
597
|
+
throw new TypeError("operatorApprovals.castVote: request " +
|
|
598
|
+
JSON.stringify(requestId) + " is " + req.status + " — votes refused");
|
|
599
|
+
}
|
|
600
|
+
if (req.requested_by === approverId) {
|
|
601
|
+
throw new TypeError("operatorApprovals.castVote: requester cannot vote on their own request");
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
var wf = await _getWorkflow(req.workflow_slug);
|
|
605
|
+
if (!wf) {
|
|
606
|
+
throw new TypeError("operatorApprovals.castVote: workflow " +
|
|
607
|
+
JSON.stringify(req.workflow_slug) + " not found");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Capability gate via composed operatorRoles peer.
|
|
611
|
+
if (operatorRoles && wf.required_capability) {
|
|
612
|
+
var allowed = await operatorRoles.hasPermission({
|
|
613
|
+
operator_id: approverId,
|
|
614
|
+
permission: wf.required_capability,
|
|
615
|
+
});
|
|
616
|
+
if (!allowed) {
|
|
617
|
+
throw new TypeError("operatorApprovals.castVote: approver " +
|
|
618
|
+
JSON.stringify(approverId) + " does not carry required capability " +
|
|
619
|
+
JSON.stringify(wf.required_capability));
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// UNIQUE(request_id, approver_id) — surface the dedup as a
|
|
624
|
+
// friendly refusal rather than a raw constraint error.
|
|
625
|
+
var existing = (await query(
|
|
626
|
+
"SELECT id FROM approval_votes WHERE request_id = ?1 AND approver_id = ?2 LIMIT 1",
|
|
627
|
+
[requestId, approverId],
|
|
628
|
+
)).rows[0];
|
|
629
|
+
if (existing) {
|
|
630
|
+
throw new TypeError("operatorApprovals.castVote: approver " +
|
|
631
|
+
JSON.stringify(approverId) + " has already voted on request " +
|
|
632
|
+
JSON.stringify(requestId));
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
var voteId = _b().uuid.v7();
|
|
636
|
+
var ts = _monotonicTs();
|
|
637
|
+
await query(
|
|
638
|
+
"INSERT INTO approval_votes (id, request_id, approver_id, decision, comment, occurred_at) " +
|
|
639
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
640
|
+
[voteId, requestId, approverId, decision, comment, ts],
|
|
641
|
+
);
|
|
642
|
+
|
|
643
|
+
// Tally bump under the same logical transaction.
|
|
644
|
+
var votesFor = Number(req.votes_for);
|
|
645
|
+
var votesAgainst = Number(req.votes_against);
|
|
646
|
+
if (decision === "approve") votesFor += 1;
|
|
647
|
+
if (decision === "reject") votesAgainst += 1;
|
|
648
|
+
|
|
649
|
+
// Threshold + veto resolution.
|
|
650
|
+
var newStatus = req.status;
|
|
651
|
+
if (decision === "reject") {
|
|
652
|
+
newStatus = "rejected";
|
|
653
|
+
} else if (decision === "approve" && votesFor >= Number(wf.required_approvers)) {
|
|
654
|
+
newStatus = "approved";
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
await query(
|
|
658
|
+
"UPDATE approval_requests SET votes_for = ?1, votes_against = ?2, status = ?3, updated_at = ?4 " +
|
|
659
|
+
"WHERE id = ?5",
|
|
660
|
+
[votesFor, votesAgainst, newStatus, ts, requestId],
|
|
661
|
+
);
|
|
662
|
+
|
|
663
|
+
await _audit("approval.vote", approverId, requestId,
|
|
664
|
+
{ status: req.status, votes_for: Number(req.votes_for), votes_against: Number(req.votes_against) },
|
|
665
|
+
{ status: newStatus, votes_for: votesFor, votes_against: votesAgainst, decision: decision });
|
|
666
|
+
|
|
667
|
+
return await getRequest(requestId);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ---- recordEscalation --------------------------------------------
|
|
671
|
+
|
|
672
|
+
async function recordEscalation(input) {
|
|
673
|
+
if (!input || typeof input !== "object") {
|
|
674
|
+
throw new TypeError("operatorApprovals.recordEscalation: input object required");
|
|
675
|
+
}
|
|
676
|
+
var requestId = _operatorId(input.request_id, "request_id");
|
|
677
|
+
var escalatedTo = _operatorId(input.escalated_to, "escalated_to");
|
|
678
|
+
var reason = _reason(input.reason);
|
|
679
|
+
|
|
680
|
+
var req = (await query(
|
|
681
|
+
"SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
|
|
682
|
+
[requestId],
|
|
683
|
+
)).rows[0];
|
|
684
|
+
if (!req) {
|
|
685
|
+
throw new TypeError("operatorApprovals.recordEscalation: request " +
|
|
686
|
+
JSON.stringify(requestId) + " not found");
|
|
687
|
+
}
|
|
688
|
+
if (req.status !== "pending") {
|
|
689
|
+
throw new TypeError("operatorApprovals.recordEscalation: request " +
|
|
690
|
+
JSON.stringify(requestId) + " is " + req.status + " — escalation refused");
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
var ts = _monotonicTs();
|
|
694
|
+
await query(
|
|
695
|
+
"UPDATE approval_requests SET status = 'escalated', escalated_to = ?1, " +
|
|
696
|
+
"escalated_at = ?2, escalation_reason = ?3, updated_at = ?2 WHERE id = ?4",
|
|
697
|
+
[escalatedTo, ts, reason, requestId],
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
await _audit("approval.escalate", escalatedTo, requestId,
|
|
701
|
+
{ status: req.status },
|
|
702
|
+
{ status: "escalated", escalated_to: escalatedTo, reason: reason });
|
|
703
|
+
|
|
704
|
+
return await getRequest(requestId);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// ---- markExecuted ------------------------------------------------
|
|
708
|
+
|
|
709
|
+
async function markExecuted(input) {
|
|
710
|
+
if (!input || typeof input !== "object") {
|
|
711
|
+
throw new TypeError("operatorApprovals.markExecuted: input object required");
|
|
712
|
+
}
|
|
713
|
+
var requestId = _operatorId(input.request_id, "request_id");
|
|
714
|
+
var executedBy = _operatorId(input.executed_by, "executed_by");
|
|
715
|
+
var resultJson = _json(input.result, "result", MAX_RESULT_BYTES);
|
|
716
|
+
|
|
717
|
+
var req = (await query(
|
|
718
|
+
"SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
|
|
719
|
+
[requestId],
|
|
720
|
+
)).rows[0];
|
|
721
|
+
if (!req) {
|
|
722
|
+
throw new TypeError("operatorApprovals.markExecuted: request " +
|
|
723
|
+
JSON.stringify(requestId) + " not found");
|
|
724
|
+
}
|
|
725
|
+
if (req.status !== "approved") {
|
|
726
|
+
throw new TypeError("operatorApprovals.markExecuted: request " +
|
|
727
|
+
JSON.stringify(requestId) + " is " + req.status + " — execution refused");
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
var ts = _monotonicTs();
|
|
731
|
+
await query(
|
|
732
|
+
"UPDATE approval_requests SET status = 'executed', executed_by = ?1, " +
|
|
733
|
+
"executed_at = ?2, result_json = ?3, updated_at = ?2 WHERE id = ?4",
|
|
734
|
+
[executedBy, ts, resultJson, requestId],
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
await _audit("approval.execute", executedBy, requestId,
|
|
738
|
+
{ status: req.status },
|
|
739
|
+
{ status: "executed" });
|
|
740
|
+
|
|
741
|
+
return await getRequest(requestId);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// ---- cancelRequest -----------------------------------------------
|
|
745
|
+
|
|
746
|
+
async function cancelRequest(input) {
|
|
747
|
+
if (!input || typeof input !== "object") {
|
|
748
|
+
throw new TypeError("operatorApprovals.cancelRequest: input object required");
|
|
749
|
+
}
|
|
750
|
+
var requestId = _operatorId(input.request_id, "request_id");
|
|
751
|
+
var reason = _reason(input.reason);
|
|
752
|
+
|
|
753
|
+
var req = (await query(
|
|
754
|
+
"SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
|
|
755
|
+
[requestId],
|
|
756
|
+
)).rows[0];
|
|
757
|
+
if (!req) {
|
|
758
|
+
throw new TypeError("operatorApprovals.cancelRequest: request " +
|
|
759
|
+
JSON.stringify(requestId) + " not found");
|
|
760
|
+
}
|
|
761
|
+
if (req.status === "executed" || req.status === "rejected" || req.status === "cancelled") {
|
|
762
|
+
throw new TypeError("operatorApprovals.cancelRequest: request " +
|
|
763
|
+
JSON.stringify(requestId) + " is " + req.status + " — cancel refused");
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
var ts = _monotonicTs();
|
|
767
|
+
await query(
|
|
768
|
+
"UPDATE approval_requests SET status = 'cancelled', cancelled_at = ?1, " +
|
|
769
|
+
"cancel_reason = ?2, updated_at = ?1 WHERE id = ?3",
|
|
770
|
+
[ts, reason, requestId],
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
await _audit("approval.cancel", req.requested_by, requestId,
|
|
774
|
+
{ status: req.status },
|
|
775
|
+
{ status: "cancelled", reason: reason });
|
|
776
|
+
|
|
777
|
+
return await getRequest(requestId);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// ---- getRequest --------------------------------------------------
|
|
781
|
+
|
|
782
|
+
async function getRequest(requestId) {
|
|
783
|
+
_operatorId(requestId, "request_id");
|
|
784
|
+
var row = (await query(
|
|
785
|
+
"SELECT * FROM approval_requests WHERE id = ?1 LIMIT 1",
|
|
786
|
+
[requestId],
|
|
787
|
+
)).rows[0];
|
|
788
|
+
if (!row) return null;
|
|
789
|
+
var req = _hydrateRequest(row);
|
|
790
|
+
|
|
791
|
+
var voteRows = (await query(
|
|
792
|
+
"SELECT * FROM approval_votes WHERE request_id = ?1 ORDER BY occurred_at ASC, id ASC",
|
|
793
|
+
[requestId],
|
|
794
|
+
)).rows;
|
|
795
|
+
var votes = [];
|
|
796
|
+
for (var i = 0; i < voteRows.length; i += 1) votes.push(_hydrateVote(voteRows[i]));
|
|
797
|
+
req.votes = votes;
|
|
798
|
+
return req;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// ---- pendingForApprover ------------------------------------------
|
|
802
|
+
|
|
803
|
+
async function pendingForApprover(input) {
|
|
804
|
+
if (!input || typeof input !== "object") {
|
|
805
|
+
throw new TypeError("operatorApprovals.pendingForApprover: input object required");
|
|
806
|
+
}
|
|
807
|
+
var approverId = _operatorId(input.approver_id, "approver_id");
|
|
808
|
+
var workflowSlug = input.workflow_slug == null ? null : _slug(input.workflow_slug, "workflow_slug");
|
|
809
|
+
var limit = _limit(input.limit);
|
|
810
|
+
|
|
811
|
+
var sql = "SELECT r.* FROM approval_requests r " +
|
|
812
|
+
"WHERE r.status IN ('pending', 'escalated') " +
|
|
813
|
+
"AND r.requested_by != ?1 " +
|
|
814
|
+
"AND NOT EXISTS (" +
|
|
815
|
+
" SELECT 1 FROM approval_votes v " +
|
|
816
|
+
" WHERE v.request_id = r.id AND v.approver_id = ?1" +
|
|
817
|
+
")";
|
|
818
|
+
var params = [approverId];
|
|
819
|
+
var idx = 2;
|
|
820
|
+
if (workflowSlug) {
|
|
821
|
+
sql += " AND r.workflow_slug = ?" + idx;
|
|
822
|
+
params.push(workflowSlug);
|
|
823
|
+
idx += 1;
|
|
824
|
+
}
|
|
825
|
+
sql += " ORDER BY r.created_at ASC, r.id ASC LIMIT ?" + idx;
|
|
826
|
+
params.push(limit);
|
|
827
|
+
|
|
828
|
+
var rows = (await query(sql, params)).rows;
|
|
829
|
+
var out = [];
|
|
830
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRequest(rows[i]));
|
|
831
|
+
return out;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// ---- myRequests --------------------------------------------------
|
|
835
|
+
|
|
836
|
+
async function myRequests(input) {
|
|
837
|
+
if (!input || typeof input !== "object") {
|
|
838
|
+
throw new TypeError("operatorApprovals.myRequests: input object required");
|
|
839
|
+
}
|
|
840
|
+
var requesterId = _operatorId(input.requester_id, "requester_id");
|
|
841
|
+
var statusFilter = input.status == null ? null : _status(input.status);
|
|
842
|
+
var limit = _limit(input.limit);
|
|
843
|
+
|
|
844
|
+
var sql, params;
|
|
845
|
+
if (statusFilter) {
|
|
846
|
+
sql = "SELECT * FROM approval_requests WHERE requested_by = ?1 AND status = ?2 " +
|
|
847
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?3";
|
|
848
|
+
params = [requesterId, statusFilter, limit];
|
|
849
|
+
} else {
|
|
850
|
+
sql = "SELECT * FROM approval_requests WHERE requested_by = ?1 " +
|
|
851
|
+
"ORDER BY created_at DESC, id DESC LIMIT ?2";
|
|
852
|
+
params = [requesterId, limit];
|
|
853
|
+
}
|
|
854
|
+
var rows = (await query(sql, params)).rows;
|
|
855
|
+
var out = [];
|
|
856
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_hydrateRequest(rows[i]));
|
|
857
|
+
return out;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ---- metricsForWorkflow ------------------------------------------
|
|
861
|
+
|
|
862
|
+
async function metricsForWorkflow(input) {
|
|
863
|
+
if (!input || typeof input !== "object") {
|
|
864
|
+
throw new TypeError("operatorApprovals.metricsForWorkflow: input object required");
|
|
865
|
+
}
|
|
866
|
+
var slug = _slug(input.slug);
|
|
867
|
+
var from = _epochMs(input.from, "from");
|
|
868
|
+
var to = _epochMs(input.to, "to");
|
|
869
|
+
if (to < from) {
|
|
870
|
+
throw new TypeError("operatorApprovals.metricsForWorkflow: to must be >= from");
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
var rows = (await query(
|
|
874
|
+
"SELECT status, created_at, updated_at, escalated_at " +
|
|
875
|
+
"FROM approval_requests " +
|
|
876
|
+
"WHERE workflow_slug = ?1 AND created_at >= ?2 AND created_at < ?3",
|
|
877
|
+
[slug, from, to],
|
|
878
|
+
)).rows;
|
|
879
|
+
|
|
880
|
+
var byStatus = { pending: 0, approved: 0, rejected: 0,
|
|
881
|
+
executed: 0, cancelled: 0, escalated: 0 };
|
|
882
|
+
var resolveLatencies = [];
|
|
883
|
+
var escalated = 0;
|
|
884
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
885
|
+
var s = rows[i].status;
|
|
886
|
+
if (byStatus[s] != null) byStatus[s] += 1;
|
|
887
|
+
if (rows[i].escalated_at != null) escalated += 1;
|
|
888
|
+
if (s === "approved" || s === "rejected" || s === "executed" || s === "cancelled") {
|
|
889
|
+
resolveLatencies.push(Number(rows[i].updated_at) - Number(rows[i].created_at));
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
var medianResolveMs = null;
|
|
894
|
+
if (resolveLatencies.length > 0) {
|
|
895
|
+
resolveLatencies.sort(function (a, b) { return a - b; });
|
|
896
|
+
var mid = resolveLatencies.length >> 1;
|
|
897
|
+
medianResolveMs = resolveLatencies.length % 2 === 1
|
|
898
|
+
? resolveLatencies[mid]
|
|
899
|
+
: Math.round((resolveLatencies[mid - 1] + resolveLatencies[mid]) / 2);
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return {
|
|
903
|
+
slug: slug,
|
|
904
|
+
from: from,
|
|
905
|
+
to: to,
|
|
906
|
+
total: rows.length,
|
|
907
|
+
by_status: byStatus,
|
|
908
|
+
escalated: escalated,
|
|
909
|
+
median_time_to_resolve_ms: medianResolveMs,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
return {
|
|
914
|
+
DECISIONS: DECISIONS.slice(),
|
|
915
|
+
STATUSES: STATUSES.slice(),
|
|
916
|
+
MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
|
|
917
|
+
MAX_RESULT_BYTES: MAX_RESULT_BYTES,
|
|
918
|
+
MAX_REQUIRED_APPROVERS: MAX_REQUIRED_APPROVERS,
|
|
919
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
920
|
+
|
|
921
|
+
defineWorkflow: defineWorkflow,
|
|
922
|
+
requestApproval: requestApproval,
|
|
923
|
+
castVote: castVote,
|
|
924
|
+
recordEscalation: recordEscalation,
|
|
925
|
+
markExecuted: markExecuted,
|
|
926
|
+
cancelRequest: cancelRequest,
|
|
927
|
+
getRequest: getRequest,
|
|
928
|
+
pendingForApprover: pendingForApprover,
|
|
929
|
+
myRequests: myRequests,
|
|
930
|
+
metricsForWorkflow: metricsForWorkflow,
|
|
931
|
+
};
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
module.exports = {
|
|
935
|
+
create: create,
|
|
936
|
+
DECISIONS: DECISIONS,
|
|
937
|
+
STATUSES: STATUSES,
|
|
938
|
+
MAX_PAYLOAD_BYTES: MAX_PAYLOAD_BYTES,
|
|
939
|
+
MAX_RESULT_BYTES: MAX_RESULT_BYTES,
|
|
940
|
+
MAX_REQUIRED_APPROVERS: MAX_REQUIRED_APPROVERS,
|
|
941
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
942
|
+
};
|