@objectstack/plugin-approvals 4.0.1
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/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +1099 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1078 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/action-executor.ts +313 -0
- package/src/approval-service.test.ts +337 -0
- package/src/approval-service.ts +731 -0
- package/src/approvals-plugin.ts +114 -0
- package/src/index.ts +36 -0
- package/src/lifecycle-hooks.ts +250 -0
- package/src/phase-b.test.ts +263 -0
- package/tsconfig.json +10 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1078 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import {
|
|
3
|
+
SysApprovalProcess as SysApprovalProcess2,
|
|
4
|
+
SysApprovalRequest as SysApprovalRequest2,
|
|
5
|
+
SysApprovalAction as SysApprovalAction2
|
|
6
|
+
} from "@objectstack/platform-objects/audit";
|
|
7
|
+
|
|
8
|
+
// src/approval-service.ts
|
|
9
|
+
import { ApprovalProcessSchema } from "@objectstack/spec/automation";
|
|
10
|
+
|
|
11
|
+
// src/action-executor.ts
|
|
12
|
+
var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
|
|
13
|
+
var noopLogger = {
|
|
14
|
+
info: () => {
|
|
15
|
+
},
|
|
16
|
+
warn: () => {
|
|
17
|
+
},
|
|
18
|
+
error: () => {
|
|
19
|
+
},
|
|
20
|
+
debug: () => {
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
var DEFAULT_WEBHOOK_TIMEOUT_MS = 5e3;
|
|
24
|
+
async function executeActions(actions, ctx, opts) {
|
|
25
|
+
if (!Array.isArray(actions) || actions.length === 0) return;
|
|
26
|
+
const log = { ...noopLogger, ...opts.logger ?? {} };
|
|
27
|
+
for (const a of actions) {
|
|
28
|
+
try {
|
|
29
|
+
await runOne(a, ctx, opts, log);
|
|
30
|
+
} catch (err) {
|
|
31
|
+
log.error?.(`[approvals] action '${a?.type ?? "<unknown>"}' failed: ${err?.message ?? err}`, {
|
|
32
|
+
action: a,
|
|
33
|
+
trigger: ctx.trigger,
|
|
34
|
+
request_id: ctx.request?.id
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function runOne(action, ctx, opts, log) {
|
|
40
|
+
if (!action || typeof action !== "object") return;
|
|
41
|
+
switch (action.type) {
|
|
42
|
+
case "field_update":
|
|
43
|
+
return runFieldUpdate(action, ctx, opts, log);
|
|
44
|
+
case "inbox_notify":
|
|
45
|
+
return runInboxNotify(action, ctx, opts, log);
|
|
46
|
+
case "webhook":
|
|
47
|
+
return runWebhook(action, ctx, opts, log);
|
|
48
|
+
case "email_alert":
|
|
49
|
+
case "script":
|
|
50
|
+
case "connector_action":
|
|
51
|
+
log.warn?.(`[approvals] action type '${action.type}' is not implemented yet \u2014 skipping`, {
|
|
52
|
+
action_name: action.name,
|
|
53
|
+
trigger: ctx.trigger
|
|
54
|
+
});
|
|
55
|
+
return;
|
|
56
|
+
default:
|
|
57
|
+
log.warn?.(`[approvals] unknown action type '${action.type}' \u2014 skipping`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async function runFieldUpdate(action, ctx, opts, log) {
|
|
61
|
+
const cfg = action.config ?? {};
|
|
62
|
+
const field = cfg.field;
|
|
63
|
+
if (!field) {
|
|
64
|
+
log.warn?.("[approvals] field_update missing config.field");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const value = resolveValueToken(cfg.value, ctx);
|
|
68
|
+
const object = ctx.process?.object_name ?? ctx.process?.object;
|
|
69
|
+
const recordId = ctx.request?.record_id;
|
|
70
|
+
if (!object || !recordId) {
|
|
71
|
+
log.warn?.("[approvals] field_update missing object/record context");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
await opts.engine.update(
|
|
75
|
+
object,
|
|
76
|
+
{ id: recordId, [field]: value },
|
|
77
|
+
{ context: SYSTEM_CTX }
|
|
78
|
+
);
|
|
79
|
+
log.debug?.(`[approvals] field_update ${object}/${recordId} set ${field}`, { value });
|
|
80
|
+
}
|
|
81
|
+
function resolveValueToken(raw, ctx) {
|
|
82
|
+
if (typeof raw !== "string") return raw;
|
|
83
|
+
switch (raw) {
|
|
84
|
+
case "$status":
|
|
85
|
+
return ctx.request?.status ?? null;
|
|
86
|
+
case "$now":
|
|
87
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
88
|
+
case "$actor":
|
|
89
|
+
return ctx.actorId ?? null;
|
|
90
|
+
case "$comment":
|
|
91
|
+
return ctx.comment ?? null;
|
|
92
|
+
case "$step":
|
|
93
|
+
return ctx.request?.current_step ?? null;
|
|
94
|
+
case "$request_id":
|
|
95
|
+
return ctx.request?.id ?? null;
|
|
96
|
+
default:
|
|
97
|
+
return raw;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function interpolate(template, ctx) {
|
|
101
|
+
if (typeof template !== "string") return template;
|
|
102
|
+
return template.replace(/\{record_id\}/g, String(ctx.request?.record_id ?? "")).replace(/\{object\}/g, String(ctx.process?.object_name ?? ctx.process?.object ?? "")).replace(/\{status\}/g, String(ctx.request?.status ?? "")).replace(/\{step\}/g, String(ctx.request?.current_step ?? "")).replace(/\{actor\}/g, String(ctx.actorId ?? "")).replace(/\{comment\}/g, String(ctx.comment ?? "")).replace(/\{process\}/g, String(ctx.process?.name ?? ""));
|
|
103
|
+
}
|
|
104
|
+
async function runInboxNotify(action, ctx, opts, log) {
|
|
105
|
+
const cfg = action.config ?? {};
|
|
106
|
+
const recipients = resolveRecipients(cfg.to, ctx);
|
|
107
|
+
if (recipients.length === 0) {
|
|
108
|
+
log.debug?.("[approvals] inbox_notify resolved no recipients \u2014 skipping");
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const title = interpolate(cfg.title ?? "Approval update", ctx);
|
|
112
|
+
const body = interpolate(cfg.body ?? "", ctx);
|
|
113
|
+
const type = String(cfg.notificationType ?? "system");
|
|
114
|
+
const rawLink = cfg.link ? interpolate(String(cfg.link), ctx) : `/console/system/approvals?requestId=${encodeURIComponent(ctx.request?.id ?? "")}`;
|
|
115
|
+
const url = /^https?:\/\//i.test(rawLink) ? rawLink : null;
|
|
116
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
117
|
+
for (const recipient of recipients) {
|
|
118
|
+
try {
|
|
119
|
+
await opts.engine.insert(
|
|
120
|
+
"sys_notification",
|
|
121
|
+
{
|
|
122
|
+
id: `notif_${cryptoRandom()}`,
|
|
123
|
+
recipient_id: String(recipient),
|
|
124
|
+
type,
|
|
125
|
+
title,
|
|
126
|
+
body,
|
|
127
|
+
url,
|
|
128
|
+
is_read: false,
|
|
129
|
+
source_object: ctx.process?.object_name ?? ctx.process?.object ?? null,
|
|
130
|
+
source_id: ctx.request?.record_id ?? null,
|
|
131
|
+
created_at: now,
|
|
132
|
+
updated_at: now
|
|
133
|
+
},
|
|
134
|
+
{ context: SYSTEM_CTX }
|
|
135
|
+
);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
log.warn?.(`[approvals] inbox_notify insert failed for ${recipient}: ${err?.message ?? err}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function resolveRecipients(to, ctx) {
|
|
142
|
+
if (Array.isArray(to)) return to.map(String).filter(Boolean);
|
|
143
|
+
if (typeof to === "string") {
|
|
144
|
+
if (to === "submitter") return ctx.request?.submitter_id ? [String(ctx.request.submitter_id)] : [];
|
|
145
|
+
if (to === "pending_approvers") {
|
|
146
|
+
const list = ctx.request?.pending_approvers ?? [];
|
|
147
|
+
if (Array.isArray(list)) return list.map(String).filter(Boolean);
|
|
148
|
+
if (typeof list === "string") return list.split(",").map((s) => s.trim()).filter(Boolean);
|
|
149
|
+
return [];
|
|
150
|
+
}
|
|
151
|
+
return [to];
|
|
152
|
+
}
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
function cryptoRandom() {
|
|
156
|
+
const g = globalThis;
|
|
157
|
+
if (g.crypto?.randomUUID) return g.crypto.randomUUID();
|
|
158
|
+
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 12)}`;
|
|
159
|
+
}
|
|
160
|
+
async function runWebhook(action, ctx, opts, log) {
|
|
161
|
+
const cfg = action.config ?? {};
|
|
162
|
+
const url = cfg.url;
|
|
163
|
+
if (!url) {
|
|
164
|
+
log.warn?.("[approvals] webhook missing config.url");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const fetchImpl = opts.fetch ?? globalThis.fetch;
|
|
168
|
+
if (!fetchImpl) {
|
|
169
|
+
log.warn?.("[approvals] webhook skipped \u2014 no fetch implementation available");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const timeoutMs = opts.webhookTimeoutMs ?? DEFAULT_WEBHOOK_TIMEOUT_MS;
|
|
173
|
+
const headers = { "Content-Type": "application/json", ...cfg.headers ?? {} };
|
|
174
|
+
const payload = {
|
|
175
|
+
trigger: ctx.trigger,
|
|
176
|
+
request: ctx.request,
|
|
177
|
+
step: ctx.step ? { name: ctx.step.name, index: ctx.request?.current_step_index } : null,
|
|
178
|
+
actor_id: ctx.actorId ?? null,
|
|
179
|
+
comment: ctx.comment ?? null,
|
|
180
|
+
process_name: ctx.process?.name,
|
|
181
|
+
object: ctx.process?.object_name ?? ctx.process?.object,
|
|
182
|
+
...cfg.body && typeof cfg.body === "object" ? cfg.body : {}
|
|
183
|
+
};
|
|
184
|
+
const controller = globalThis.AbortController ? new globalThis.AbortController() : null;
|
|
185
|
+
const timer = setTimeout(() => controller?.abort(), timeoutMs);
|
|
186
|
+
try {
|
|
187
|
+
const res = await fetchImpl(url, {
|
|
188
|
+
method: cfg.method ?? "POST",
|
|
189
|
+
headers,
|
|
190
|
+
body: JSON.stringify(payload),
|
|
191
|
+
signal: controller?.signal
|
|
192
|
+
});
|
|
193
|
+
if (!res.ok) {
|
|
194
|
+
log.warn?.(`[approvals] webhook ${url} \u2192 ${res.status} ${res.statusText}`);
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
log.warn?.(`[approvals] webhook ${url} failed: ${err?.message ?? err}`);
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/approval-service.ts
|
|
204
|
+
var SYSTEM_CTX2 = { isSystem: true, roles: [], permissions: [] };
|
|
205
|
+
function uid(prefix) {
|
|
206
|
+
const g = globalThis;
|
|
207
|
+
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
208
|
+
return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
209
|
+
}
|
|
210
|
+
function parseJson(raw, fallback) {
|
|
211
|
+
if (raw == null || raw === "") return fallback;
|
|
212
|
+
if (typeof raw === "string") {
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(raw);
|
|
215
|
+
} catch {
|
|
216
|
+
return fallback;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return raw;
|
|
220
|
+
}
|
|
221
|
+
function csvSplit(raw) {
|
|
222
|
+
if (!raw) return [];
|
|
223
|
+
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
224
|
+
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
225
|
+
}
|
|
226
|
+
function rowFromProcess(row) {
|
|
227
|
+
return {
|
|
228
|
+
id: String(row.id),
|
|
229
|
+
name: String(row.name ?? ""),
|
|
230
|
+
label: String(row.label ?? ""),
|
|
231
|
+
object_name: String(row.object_name ?? ""),
|
|
232
|
+
description: row.description ?? void 0,
|
|
233
|
+
active: row.active !== false,
|
|
234
|
+
definition: parseJson(row.definition_json, {}),
|
|
235
|
+
created_at: row.created_at ?? void 0,
|
|
236
|
+
updated_at: row.updated_at ?? void 0
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function rowFromRequest(row) {
|
|
240
|
+
return {
|
|
241
|
+
id: String(row.id),
|
|
242
|
+
organization_id: row.organization_id ?? void 0,
|
|
243
|
+
process_name: String(row.process_name ?? ""),
|
|
244
|
+
object_name: String(row.object_name ?? ""),
|
|
245
|
+
record_id: String(row.record_id ?? ""),
|
|
246
|
+
submitter_id: row.submitter_id ?? void 0,
|
|
247
|
+
submitter_comment: row.submitter_comment ?? void 0,
|
|
248
|
+
status: row.status ?? "pending",
|
|
249
|
+
current_step: row.current_step ?? void 0,
|
|
250
|
+
current_step_index: row.current_step_index ?? void 0,
|
|
251
|
+
pending_approvers: csvSplit(row.pending_approvers),
|
|
252
|
+
payload: parseJson(row.payload_json, void 0),
|
|
253
|
+
completed_at: row.completed_at ?? void 0,
|
|
254
|
+
created_at: row.created_at ?? void 0,
|
|
255
|
+
updated_at: row.updated_at ?? void 0
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function rowFromAction(row) {
|
|
259
|
+
return {
|
|
260
|
+
id: String(row.id),
|
|
261
|
+
request_id: String(row.request_id),
|
|
262
|
+
step_name: row.step_name ?? void 0,
|
|
263
|
+
step_index: row.step_index ?? void 0,
|
|
264
|
+
action: row.action,
|
|
265
|
+
actor_id: row.actor_id ?? void 0,
|
|
266
|
+
comment: row.comment ?? void 0,
|
|
267
|
+
created_at: row.created_at ?? void 0
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
var ApprovalService = class {
|
|
271
|
+
constructor(opts) {
|
|
272
|
+
this.engine = opts.engine;
|
|
273
|
+
this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
|
|
274
|
+
this.logger = opts.logger;
|
|
275
|
+
this.fetchImpl = opts.fetch;
|
|
276
|
+
this.webhookTimeoutMs = opts.webhookTimeoutMs;
|
|
277
|
+
this.onRegistryChange = opts.onRegistryChange;
|
|
278
|
+
}
|
|
279
|
+
/** Allow the plugin to attach a hook re-binding callback after construction. */
|
|
280
|
+
setRegistryChangeHandler(handler) {
|
|
281
|
+
this.onRegistryChange = handler;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Expand the approvers on a step into user IDs by querying the graph
|
|
285
|
+
* tables for `team:` / `department:` / `role:` / `manager:` approver
|
|
286
|
+
* types. Falls back to a prefixed literal (`type:value`) when graph
|
|
287
|
+
* lookups produce nothing — so existing test fixtures and approver
|
|
288
|
+
* flows that rely on substring matching keep working.
|
|
289
|
+
*
|
|
290
|
+
* **Graph semantics (M10.17.1):**
|
|
291
|
+
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
292
|
+
* - `department` → recursive BFS of `sys_department.parent_department_id`
|
|
293
|
+
* → members of every descendant via `sys_department_member`
|
|
294
|
+
* - `role` → users with `sys_member.role = value` in tenant
|
|
295
|
+
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
296
|
+
* - `field` → literal user id stored in `record[value]`
|
|
297
|
+
* - `user` → literal value
|
|
298
|
+
*/
|
|
299
|
+
async expandApprovers(step, record, organizationId) {
|
|
300
|
+
if (!step || !Array.isArray(step.approvers)) return [];
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const a of step.approvers) {
|
|
303
|
+
if (!a) continue;
|
|
304
|
+
if (a.type === "user") {
|
|
305
|
+
out.push(String(a.value));
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
if (a.type === "field" && record) {
|
|
309
|
+
out.push(String(record[a.value] ?? ""));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
try {
|
|
313
|
+
if (a.type === "team") {
|
|
314
|
+
const users = await this.expandTeamUsers(String(a.value));
|
|
315
|
+
if (users.length) {
|
|
316
|
+
for (const u of users) out.push(u);
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
} else if (a.type === "department" || a.type === "dept") {
|
|
320
|
+
const users = await this.expandDepartmentUsers(String(a.value), organizationId);
|
|
321
|
+
if (users.length) {
|
|
322
|
+
for (const u of users) out.push(u);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
} else if (a.type === "role") {
|
|
326
|
+
const users = await this.expandRoleUsers(String(a.value), organizationId);
|
|
327
|
+
if (users.length) {
|
|
328
|
+
for (const u of users) out.push(u);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
} else if (a.type === "manager" && record) {
|
|
332
|
+
const subject = record[a.value] ?? record.owner_id;
|
|
333
|
+
if (subject) {
|
|
334
|
+
const mgr = await this.lookupManager(String(subject));
|
|
335
|
+
if (mgr) {
|
|
336
|
+
out.push(mgr);
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} catch {
|
|
342
|
+
}
|
|
343
|
+
out.push(`${a.type}:${a.value}`);
|
|
344
|
+
}
|
|
345
|
+
return out.filter(Boolean);
|
|
346
|
+
}
|
|
347
|
+
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
348
|
+
async expandTeamUsers(teamId) {
|
|
349
|
+
if (!teamId) return [];
|
|
350
|
+
let rows = [];
|
|
351
|
+
try {
|
|
352
|
+
rows = await this.engine.find("sys_team_member", {
|
|
353
|
+
filter: { team_id: teamId },
|
|
354
|
+
fields: ["user_id"],
|
|
355
|
+
limit: 1e4,
|
|
356
|
+
context: SYSTEM_CTX2
|
|
357
|
+
});
|
|
358
|
+
} catch {
|
|
359
|
+
rows = [];
|
|
360
|
+
}
|
|
361
|
+
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
362
|
+
}
|
|
363
|
+
/** Recursive department — walks `sys_department.parent_department_id`. */
|
|
364
|
+
async expandDepartmentUsers(departmentId, organizationId) {
|
|
365
|
+
if (!departmentId) return [];
|
|
366
|
+
try {
|
|
367
|
+
const seed = await this.engine.find("sys_department", {
|
|
368
|
+
filter: organizationId ? { id: departmentId, organization_id: organizationId } : { id: departmentId },
|
|
369
|
+
fields: ["id", "active"],
|
|
370
|
+
limit: 1,
|
|
371
|
+
context: SYSTEM_CTX2
|
|
372
|
+
});
|
|
373
|
+
const seedRow = Array.isArray(seed) ? seed[0] : null;
|
|
374
|
+
if (!seedRow || seedRow.active === false) return [];
|
|
375
|
+
} catch {
|
|
376
|
+
return [];
|
|
377
|
+
}
|
|
378
|
+
const seen = /* @__PURE__ */ new Set([departmentId]);
|
|
379
|
+
const queue = [departmentId];
|
|
380
|
+
while (queue.length) {
|
|
381
|
+
const parent = queue.shift();
|
|
382
|
+
let kids = [];
|
|
383
|
+
try {
|
|
384
|
+
const filter = { parent_department_id: parent, active: { $ne: false } };
|
|
385
|
+
if (organizationId) filter.organization_id = organizationId;
|
|
386
|
+
kids = await this.engine.find("sys_department", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
|
|
387
|
+
} catch {
|
|
388
|
+
kids = [];
|
|
389
|
+
}
|
|
390
|
+
for (const k of kids ?? []) {
|
|
391
|
+
const kid = String(k.id ?? "");
|
|
392
|
+
if (kid && !seen.has(kid)) {
|
|
393
|
+
seen.add(kid);
|
|
394
|
+
queue.push(kid);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
let rows = [];
|
|
399
|
+
try {
|
|
400
|
+
rows = await this.engine.find("sys_department_member", {
|
|
401
|
+
filter: { department_id: { $in: Array.from(seen) } },
|
|
402
|
+
fields: ["user_id"],
|
|
403
|
+
limit: 1e4,
|
|
404
|
+
context: SYSTEM_CTX2
|
|
405
|
+
});
|
|
406
|
+
} catch {
|
|
407
|
+
rows = [];
|
|
408
|
+
}
|
|
409
|
+
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
410
|
+
}
|
|
411
|
+
async expandRoleUsers(roleName, organizationId) {
|
|
412
|
+
if (!roleName) return [];
|
|
413
|
+
const filter = { role: roleName };
|
|
414
|
+
if (organizationId) filter.organization_id = organizationId;
|
|
415
|
+
let rows = [];
|
|
416
|
+
try {
|
|
417
|
+
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
|
|
418
|
+
} catch {
|
|
419
|
+
rows = [];
|
|
420
|
+
}
|
|
421
|
+
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
422
|
+
}
|
|
423
|
+
async lookupManager(userId) {
|
|
424
|
+
try {
|
|
425
|
+
const rows = await this.engine.find("sys_user", {
|
|
426
|
+
filter: { id: userId },
|
|
427
|
+
fields: ["id", "manager_id"],
|
|
428
|
+
limit: 1,
|
|
429
|
+
context: SYSTEM_CTX2
|
|
430
|
+
});
|
|
431
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
432
|
+
return row?.manager_id ? String(row.manager_id) : null;
|
|
433
|
+
} catch {
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
async notifyRegistryChanged() {
|
|
438
|
+
const cb = this.onRegistryChange ?? this.onRegistryChange;
|
|
439
|
+
if (!cb) return;
|
|
440
|
+
try {
|
|
441
|
+
await cb();
|
|
442
|
+
} catch (err) {
|
|
443
|
+
this.logger?.warn?.("[approvals] onRegistryChange handler failed", { error: err?.message });
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
/** Mirror request status onto `process.approvalStatusField` if configured. */
|
|
447
|
+
async syncStatusField(process, request) {
|
|
448
|
+
const field = process.definition?.approvalStatusField;
|
|
449
|
+
if (!field) return;
|
|
450
|
+
try {
|
|
451
|
+
await this.engine.update(
|
|
452
|
+
process.object_name,
|
|
453
|
+
{ id: request.record_id, [field]: request.status },
|
|
454
|
+
{ context: SYSTEM_CTX2 }
|
|
455
|
+
);
|
|
456
|
+
} catch (err) {
|
|
457
|
+
this.logger?.warn?.(`[approvals] syncStatusField failed: ${err?.message ?? err}`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** Convenience wrapper that funnels every action invocation through the executor. */
|
|
461
|
+
async runActions(actions, trigger, process, request, step, actorId, comment) {
|
|
462
|
+
if (!actions || actions.length === 0) return;
|
|
463
|
+
await executeActions(actions, {
|
|
464
|
+
trigger,
|
|
465
|
+
process: { ...process, object: process.object_name },
|
|
466
|
+
request,
|
|
467
|
+
step,
|
|
468
|
+
actorId: actorId ?? null,
|
|
469
|
+
comment: comment ?? null
|
|
470
|
+
}, {
|
|
471
|
+
engine: this.engine,
|
|
472
|
+
logger: this.logger,
|
|
473
|
+
fetch: this.fetchImpl,
|
|
474
|
+
webhookTimeoutMs: this.webhookTimeoutMs
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
// ── Process definitions ──────────────────────────────────────
|
|
478
|
+
async defineProcess(input, _context) {
|
|
479
|
+
if (!input.name) throw new Error("VALIDATION_FAILED: name is required");
|
|
480
|
+
if (!input.label) throw new Error("VALIDATION_FAILED: label is required");
|
|
481
|
+
if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
|
|
482
|
+
if (!input.definition) throw new Error("VALIDATION_FAILED: definition is required");
|
|
483
|
+
const parsed = ApprovalProcessSchema.safeParse(input.definition);
|
|
484
|
+
if (!parsed.success) {
|
|
485
|
+
const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
486
|
+
throw new Error(`VALIDATION_FAILED: ${msg}`);
|
|
487
|
+
}
|
|
488
|
+
const now = this.clock.now().toISOString();
|
|
489
|
+
const payload = {
|
|
490
|
+
name: input.name,
|
|
491
|
+
label: input.label,
|
|
492
|
+
object_name: input.object,
|
|
493
|
+
description: input.description ?? null,
|
|
494
|
+
active: input.active !== false,
|
|
495
|
+
definition_json: JSON.stringify(parsed.data),
|
|
496
|
+
updated_at: now
|
|
497
|
+
};
|
|
498
|
+
const existing = await this.engine.find("sys_approval_process", {
|
|
499
|
+
where: { name: input.name },
|
|
500
|
+
limit: 1,
|
|
501
|
+
context: SYSTEM_CTX2
|
|
502
|
+
});
|
|
503
|
+
if (Array.isArray(existing) && existing[0]) {
|
|
504
|
+
const id2 = existing[0].id;
|
|
505
|
+
await this.engine.update("sys_approval_process", { id: id2, ...payload }, { context: SYSTEM_CTX2 });
|
|
506
|
+
const row2 = rowFromProcess({ ...existing[0], ...payload, id: id2 });
|
|
507
|
+
await this.notifyRegistryChanged();
|
|
508
|
+
return row2;
|
|
509
|
+
}
|
|
510
|
+
const id = input.id ?? uid("apv");
|
|
511
|
+
const row = { id, ...payload, created_at: now };
|
|
512
|
+
await this.engine.insert("sys_approval_process", row, { context: SYSTEM_CTX2 });
|
|
513
|
+
const out = rowFromProcess(row);
|
|
514
|
+
await this.notifyRegistryChanged();
|
|
515
|
+
return out;
|
|
516
|
+
}
|
|
517
|
+
async listProcesses(filter, _context) {
|
|
518
|
+
const f = {};
|
|
519
|
+
if (filter?.object) f.object_name = filter.object;
|
|
520
|
+
if (filter?.activeOnly) f.active = true;
|
|
521
|
+
const rows = await this.engine.find("sys_approval_process", {
|
|
522
|
+
where: f,
|
|
523
|
+
limit: 500,
|
|
524
|
+
orderBy: [{ field: "updated_at", direction: "desc" }],
|
|
525
|
+
context: SYSTEM_CTX2
|
|
526
|
+
});
|
|
527
|
+
return Array.isArray(rows) ? rows.map(rowFromProcess) : [];
|
|
528
|
+
}
|
|
529
|
+
async getProcess(idOrName, _context) {
|
|
530
|
+
if (!idOrName) return null;
|
|
531
|
+
let rows = await this.engine.find("sys_approval_process", {
|
|
532
|
+
where: { id: idOrName },
|
|
533
|
+
limit: 1,
|
|
534
|
+
context: SYSTEM_CTX2
|
|
535
|
+
});
|
|
536
|
+
if (!Array.isArray(rows) || !rows[0]) {
|
|
537
|
+
rows = await this.engine.find("sys_approval_process", {
|
|
538
|
+
where: { name: idOrName },
|
|
539
|
+
limit: 1,
|
|
540
|
+
context: SYSTEM_CTX2
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return Array.isArray(rows) && rows[0] ? rowFromProcess(rows[0]) : null;
|
|
544
|
+
}
|
|
545
|
+
async deleteProcess(idOrName, context) {
|
|
546
|
+
if (!idOrName) throw new Error("VALIDATION_FAILED: idOrName is required");
|
|
547
|
+
const proc = await this.getProcess(idOrName, context);
|
|
548
|
+
if (!proc) return;
|
|
549
|
+
await this.engine.delete("sys_approval_process", { where: { id: proc.id }, context: SYSTEM_CTX2 });
|
|
550
|
+
await this.notifyRegistryChanged();
|
|
551
|
+
}
|
|
552
|
+
// ── Requests ─────────────────────────────────────────────────
|
|
553
|
+
async submit(input, context) {
|
|
554
|
+
if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
|
|
555
|
+
if (!input.recordId) throw new Error("VALIDATION_FAILED: recordId is required");
|
|
556
|
+
let process = null;
|
|
557
|
+
if (input.processName) {
|
|
558
|
+
process = await this.getProcess(input.processName, context);
|
|
559
|
+
if (process && !process.active) {
|
|
560
|
+
throw new Error(`NO_ACTIVE_PROCESS: process '${input.processName}' is not active`);
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
const list = await this.listProcesses({ object: input.object, activeOnly: true }, context);
|
|
564
|
+
process = list[0] ?? null;
|
|
565
|
+
}
|
|
566
|
+
if (!process) {
|
|
567
|
+
throw new Error(`NO_ACTIVE_PROCESS: no active approval process for object '${input.object}'`);
|
|
568
|
+
}
|
|
569
|
+
const existing = await this.engine.find("sys_approval_request", {
|
|
570
|
+
where: { object_name: input.object, record_id: input.recordId, status: "pending" },
|
|
571
|
+
limit: 1,
|
|
572
|
+
context: SYSTEM_CTX2
|
|
573
|
+
});
|
|
574
|
+
if (Array.isArray(existing) && existing[0]) {
|
|
575
|
+
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
576
|
+
}
|
|
577
|
+
const steps = process.definition?.steps ?? [];
|
|
578
|
+
if (steps.length === 0) {
|
|
579
|
+
throw new Error("VALIDATION_FAILED: process definition has no steps");
|
|
580
|
+
}
|
|
581
|
+
const step0 = steps[0];
|
|
582
|
+
const ctxOrg = context?.organizationId ?? context?.tenantId ?? null;
|
|
583
|
+
const approvers = await this.expandApprovers(step0, input.payload, ctxOrg);
|
|
584
|
+
const now = this.clock.now().toISOString();
|
|
585
|
+
const id = uid("areq");
|
|
586
|
+
const row = {
|
|
587
|
+
id,
|
|
588
|
+
process_name: process.name,
|
|
589
|
+
object_name: input.object,
|
|
590
|
+
record_id: input.recordId,
|
|
591
|
+
submitter_id: input.submitterId ?? context.userId ?? null,
|
|
592
|
+
submitter_comment: input.comment ?? null,
|
|
593
|
+
status: "pending",
|
|
594
|
+
current_step: step0.name,
|
|
595
|
+
current_step_index: 0,
|
|
596
|
+
pending_approvers: approvers.join(","),
|
|
597
|
+
payload_json: input.payload != null ? JSON.stringify(input.payload) : null,
|
|
598
|
+
organization_id: ctxOrg,
|
|
599
|
+
created_at: now,
|
|
600
|
+
updated_at: now
|
|
601
|
+
};
|
|
602
|
+
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
603
|
+
await this.engine.insert("sys_approval_action", {
|
|
604
|
+
id: uid("aact"),
|
|
605
|
+
request_id: id,
|
|
606
|
+
organization_id: ctxOrg,
|
|
607
|
+
step_name: step0.name,
|
|
608
|
+
step_index: 0,
|
|
609
|
+
action: "submit",
|
|
610
|
+
actor_id: input.submitterId ?? context.userId ?? null,
|
|
611
|
+
comment: input.comment ?? null,
|
|
612
|
+
created_at: now
|
|
613
|
+
}, { context: SYSTEM_CTX2 });
|
|
614
|
+
const requestRow = rowFromRequest(row);
|
|
615
|
+
await this.syncStatusField(process, requestRow);
|
|
616
|
+
const definition = process.definition ?? {};
|
|
617
|
+
await this.runActions(
|
|
618
|
+
definition.onSubmit,
|
|
619
|
+
"submit",
|
|
620
|
+
process,
|
|
621
|
+
requestRow,
|
|
622
|
+
step0,
|
|
623
|
+
input.submitterId ?? context.userId ?? null,
|
|
624
|
+
input.comment ?? null
|
|
625
|
+
);
|
|
626
|
+
return requestRow;
|
|
627
|
+
}
|
|
628
|
+
async listRequests(filter, context) {
|
|
629
|
+
const f = {};
|
|
630
|
+
if (filter?.object) f.object_name = filter.object;
|
|
631
|
+
if (filter?.recordId) f.record_id = filter.recordId;
|
|
632
|
+
if (filter?.submitterId) f.submitter_id = filter.submitterId;
|
|
633
|
+
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
634
|
+
if (tenantOrg) f.organization_id = tenantOrg;
|
|
635
|
+
let statusFilter;
|
|
636
|
+
if (Array.isArray(filter?.status)) statusFilter = filter.status;
|
|
637
|
+
else if (filter?.status) f.status = filter.status;
|
|
638
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
639
|
+
where: f,
|
|
640
|
+
limit: 500,
|
|
641
|
+
orderBy: [{ field: "updated_at", direction: "desc" }],
|
|
642
|
+
context: SYSTEM_CTX2
|
|
643
|
+
});
|
|
644
|
+
let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
645
|
+
if (statusFilter) list = list.filter((r) => statusFilter.includes(r.status));
|
|
646
|
+
if (filter?.approverId) {
|
|
647
|
+
const target = filter.approverId;
|
|
648
|
+
list = list.filter((r) => (r.pending_approvers ?? []).includes(target));
|
|
649
|
+
}
|
|
650
|
+
return list;
|
|
651
|
+
}
|
|
652
|
+
async getRequest(requestId, context) {
|
|
653
|
+
if (!requestId) return null;
|
|
654
|
+
const where = { id: requestId };
|
|
655
|
+
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
656
|
+
if (tenantOrg) where.organization_id = tenantOrg;
|
|
657
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
658
|
+
where,
|
|
659
|
+
limit: 1,
|
|
660
|
+
context: SYSTEM_CTX2
|
|
661
|
+
});
|
|
662
|
+
return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
|
|
663
|
+
}
|
|
664
|
+
async approve(requestId, input, context) {
|
|
665
|
+
const req = await this.getRequest(requestId, context);
|
|
666
|
+
if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
667
|
+
if (req.status !== "pending") throw new Error(`INVALID_STATE: request is ${req.status}`);
|
|
668
|
+
if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
669
|
+
if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
|
|
670
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
671
|
+
}
|
|
672
|
+
const process = await this.getProcess(req.process_name, context);
|
|
673
|
+
if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
|
|
674
|
+
const steps = process.definition?.steps ?? [];
|
|
675
|
+
const stepIndex = req.current_step_index ?? 0;
|
|
676
|
+
const step = steps[stepIndex];
|
|
677
|
+
if (!step) throw new Error(`INVALID_STATE: step index ${stepIndex} out of range`);
|
|
678
|
+
const now = this.clock.now().toISOString();
|
|
679
|
+
await this.engine.insert("sys_approval_action", {
|
|
680
|
+
id: uid("aact"),
|
|
681
|
+
request_id: req.id,
|
|
682
|
+
organization_id: req.organization_id ?? null,
|
|
683
|
+
step_name: step.name,
|
|
684
|
+
step_index: stepIndex,
|
|
685
|
+
action: "approve",
|
|
686
|
+
actor_id: input.actorId,
|
|
687
|
+
comment: input.comment ?? null,
|
|
688
|
+
created_at: now
|
|
689
|
+
}, { context: SYSTEM_CTX2 });
|
|
690
|
+
if (step.behavior === "unanimous") {
|
|
691
|
+
const original = await this.expandApprovers(step, req.payload, req.organization_id ?? null);
|
|
692
|
+
const acts = await this.engine.find("sys_approval_action", {
|
|
693
|
+
where: { request_id: req.id, step_index: stepIndex, action: "approve" },
|
|
694
|
+
limit: 500,
|
|
695
|
+
context: SYSTEM_CTX2
|
|
696
|
+
});
|
|
697
|
+
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
698
|
+
const stillPending = original.filter((a) => !approved.has(a));
|
|
699
|
+
if (stillPending.length > 0) {
|
|
700
|
+
await this.engine.update("sys_approval_request", {
|
|
701
|
+
id: req.id,
|
|
702
|
+
pending_approvers: stillPending.join(","),
|
|
703
|
+
updated_at: now
|
|
704
|
+
}, { context: SYSTEM_CTX2 });
|
|
705
|
+
const fresh2 = await this.getRequest(req.id, context);
|
|
706
|
+
return { request: fresh2, finalized: false };
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (stepIndex + 1 >= steps.length) {
|
|
710
|
+
await this.engine.update("sys_approval_request", {
|
|
711
|
+
id: req.id,
|
|
712
|
+
status: "approved",
|
|
713
|
+
pending_approvers: null,
|
|
714
|
+
completed_at: now,
|
|
715
|
+
updated_at: now
|
|
716
|
+
}, { context: SYSTEM_CTX2 });
|
|
717
|
+
const fresh2 = await this.getRequest(req.id, context);
|
|
718
|
+
await this.runActions(step?.onApprove, "step_approve", process, fresh2, step, input.actorId, input.comment);
|
|
719
|
+
await this.syncStatusField(process, fresh2);
|
|
720
|
+
await this.runActions(process.definition?.onFinalApprove, "final_approve", process, fresh2, step, input.actorId, input.comment);
|
|
721
|
+
return { request: fresh2, finalized: true };
|
|
722
|
+
}
|
|
723
|
+
const nextStep = steps[stepIndex + 1];
|
|
724
|
+
const nextApprovers = await this.expandApprovers(nextStep, req.payload, req.organization_id ?? null);
|
|
725
|
+
await this.engine.update("sys_approval_request", {
|
|
726
|
+
id: req.id,
|
|
727
|
+
current_step: nextStep.name,
|
|
728
|
+
current_step_index: stepIndex + 1,
|
|
729
|
+
pending_approvers: nextApprovers.join(","),
|
|
730
|
+
updated_at: now
|
|
731
|
+
}, { context: SYSTEM_CTX2 });
|
|
732
|
+
const fresh = await this.getRequest(req.id, context);
|
|
733
|
+
await this.runActions(step?.onApprove, "step_approve", process, fresh, step, input.actorId, input.comment);
|
|
734
|
+
return { request: fresh, finalized: false };
|
|
735
|
+
}
|
|
736
|
+
async reject(requestId, input, context) {
|
|
737
|
+
const req = await this.getRequest(requestId, context);
|
|
738
|
+
if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
739
|
+
if (req.status !== "pending") throw new Error(`INVALID_STATE: request is ${req.status}`);
|
|
740
|
+
if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
741
|
+
if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
|
|
742
|
+
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
743
|
+
}
|
|
744
|
+
const process = await this.getProcess(req.process_name, context);
|
|
745
|
+
if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
|
|
746
|
+
const steps = process.definition?.steps ?? [];
|
|
747
|
+
const stepIndex = req.current_step_index ?? 0;
|
|
748
|
+
const step = steps[stepIndex];
|
|
749
|
+
const now = this.clock.now().toISOString();
|
|
750
|
+
await this.engine.insert("sys_approval_action", {
|
|
751
|
+
id: uid("aact"),
|
|
752
|
+
request_id: req.id,
|
|
753
|
+
organization_id: req.organization_id ?? null,
|
|
754
|
+
step_name: step?.name,
|
|
755
|
+
step_index: stepIndex,
|
|
756
|
+
action: "reject",
|
|
757
|
+
actor_id: input.actorId,
|
|
758
|
+
comment: input.comment ?? null,
|
|
759
|
+
created_at: now
|
|
760
|
+
}, { context: SYSTEM_CTX2 });
|
|
761
|
+
if (step?.rejectionBehavior === "back_to_previous" && stepIndex > 0) {
|
|
762
|
+
const prev = steps[stepIndex - 1];
|
|
763
|
+
const prevApprovers = await this.expandApprovers(prev, req.payload, req.organization_id ?? null);
|
|
764
|
+
await this.engine.update("sys_approval_request", {
|
|
765
|
+
id: req.id,
|
|
766
|
+
current_step: prev.name,
|
|
767
|
+
current_step_index: stepIndex - 1,
|
|
768
|
+
pending_approvers: prevApprovers.join(","),
|
|
769
|
+
updated_at: now
|
|
770
|
+
}, { context: SYSTEM_CTX2 });
|
|
771
|
+
const fresh2 = await this.getRequest(req.id, context);
|
|
772
|
+
await this.runActions(step?.onReject, "step_reject", process, fresh2, step, input.actorId, input.comment);
|
|
773
|
+
return { request: fresh2, finalized: false };
|
|
774
|
+
}
|
|
775
|
+
await this.engine.update("sys_approval_request", {
|
|
776
|
+
id: req.id,
|
|
777
|
+
status: "rejected",
|
|
778
|
+
pending_approvers: null,
|
|
779
|
+
completed_at: now,
|
|
780
|
+
updated_at: now
|
|
781
|
+
}, { context: SYSTEM_CTX2 });
|
|
782
|
+
const fresh = await this.getRequest(req.id, context);
|
|
783
|
+
await this.runActions(step?.onReject, "step_reject", process, fresh, step, input.actorId, input.comment);
|
|
784
|
+
await this.syncStatusField(process, fresh);
|
|
785
|
+
await this.runActions(process.definition?.onFinalReject, "final_reject", process, fresh, step, input.actorId, input.comment);
|
|
786
|
+
return { request: fresh, finalized: true };
|
|
787
|
+
}
|
|
788
|
+
async recall(requestId, input, context) {
|
|
789
|
+
const req = await this.getRequest(requestId, context);
|
|
790
|
+
if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
791
|
+
if (req.status !== "pending") throw new Error(`INVALID_STATE: request is ${req.status}`);
|
|
792
|
+
if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
793
|
+
if (!context.isSystem && req.submitter_id && req.submitter_id !== input.actorId) {
|
|
794
|
+
throw new Error(`FORBIDDEN: only the submitter can recall this request`);
|
|
795
|
+
}
|
|
796
|
+
const now = this.clock.now().toISOString();
|
|
797
|
+
await this.engine.insert("sys_approval_action", {
|
|
798
|
+
id: uid("aact"),
|
|
799
|
+
request_id: req.id,
|
|
800
|
+
organization_id: req.organization_id ?? null,
|
|
801
|
+
step_name: req.current_step,
|
|
802
|
+
step_index: req.current_step_index,
|
|
803
|
+
action: "recall",
|
|
804
|
+
actor_id: input.actorId,
|
|
805
|
+
comment: input.comment ?? null,
|
|
806
|
+
created_at: now
|
|
807
|
+
}, { context: SYSTEM_CTX2 });
|
|
808
|
+
await this.engine.update("sys_approval_request", {
|
|
809
|
+
id: req.id,
|
|
810
|
+
status: "recalled",
|
|
811
|
+
pending_approvers: null,
|
|
812
|
+
completed_at: now,
|
|
813
|
+
updated_at: now
|
|
814
|
+
}, { context: SYSTEM_CTX2 });
|
|
815
|
+
const fresh = await this.getRequest(req.id, context);
|
|
816
|
+
const process = await this.getProcess(req.process_name, context);
|
|
817
|
+
if (process) {
|
|
818
|
+
await this.syncStatusField(process, fresh);
|
|
819
|
+
await this.runActions(process.definition?.onRecall, "recall", process, fresh, void 0, input.actorId, input.comment);
|
|
820
|
+
}
|
|
821
|
+
return { request: fresh, finalized: true };
|
|
822
|
+
}
|
|
823
|
+
async listActions(requestId, context) {
|
|
824
|
+
if (!requestId) return [];
|
|
825
|
+
const req = await this.getRequest(requestId, context);
|
|
826
|
+
if (!req) return [];
|
|
827
|
+
const rows = await this.engine.find("sys_approval_action", {
|
|
828
|
+
where: { request_id: requestId },
|
|
829
|
+
limit: 500,
|
|
830
|
+
orderBy: [{ field: "created_at", direction: "asc" }],
|
|
831
|
+
context: SYSTEM_CTX2
|
|
832
|
+
});
|
|
833
|
+
return Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
|
|
837
|
+
// src/approvals-plugin.ts
|
|
838
|
+
import {
|
|
839
|
+
SysApprovalProcess,
|
|
840
|
+
SysApprovalRequest,
|
|
841
|
+
SysApprovalAction
|
|
842
|
+
} from "@objectstack/platform-objects/audit";
|
|
843
|
+
|
|
844
|
+
// src/lifecycle-hooks.ts
|
|
845
|
+
import { ExpressionEngine } from "@objectstack/formula";
|
|
846
|
+
var APPROVALS_HOOK_PACKAGE = "plugin-approvals:auto";
|
|
847
|
+
var SYSTEM_CTX3 = { isSystem: true, roles: [], permissions: [] };
|
|
848
|
+
function evaluateCriteria(criteria, record, logger) {
|
|
849
|
+
if (criteria == null || criteria === "") return true;
|
|
850
|
+
let expr;
|
|
851
|
+
if (typeof criteria === "string") {
|
|
852
|
+
expr = { dialect: "cel", source: criteria };
|
|
853
|
+
} else if (typeof criteria === "object" && criteria.dialect) {
|
|
854
|
+
expr = criteria;
|
|
855
|
+
} else {
|
|
856
|
+
return true;
|
|
857
|
+
}
|
|
858
|
+
if (!expr.source || !expr.source.trim()) return true;
|
|
859
|
+
const r = ExpressionEngine.evaluate(expr, { record });
|
|
860
|
+
if (!r.ok) {
|
|
861
|
+
logger?.warn?.("[approvals] entryCriteria evaluation failed; skipping auto-submit", {
|
|
862
|
+
source: expr.source,
|
|
863
|
+
error: r.error.message
|
|
864
|
+
});
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
return Boolean(r.value);
|
|
868
|
+
}
|
|
869
|
+
async function hasPendingRequest(engine, objectName, recordId) {
|
|
870
|
+
try {
|
|
871
|
+
const rows = await engine.find("sys_approval_request", {
|
|
872
|
+
where: { object_name: objectName, record_id: String(recordId), status: "pending" },
|
|
873
|
+
limit: 1
|
|
874
|
+
});
|
|
875
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
function bindProcessHooks(engine, service, processes, logger) {
|
|
881
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
882
|
+
for (const p of processes) {
|
|
883
|
+
if (!p.active && !p.is_active) continue;
|
|
884
|
+
if (!p.object_name) continue;
|
|
885
|
+
const list = byObject.get(p.object_name) ?? [];
|
|
886
|
+
list.push(p);
|
|
887
|
+
byObject.set(p.object_name, list);
|
|
888
|
+
}
|
|
889
|
+
for (const [objectName, procs] of byObject.entries()) {
|
|
890
|
+
engine.registerHook("afterInsert", async (ctx) => {
|
|
891
|
+
try {
|
|
892
|
+
const record = ctx?.result ?? ctx?.input?.data ?? {};
|
|
893
|
+
const id = String(record?.id ?? "");
|
|
894
|
+
if (!id) return;
|
|
895
|
+
for (const proc of procs) {
|
|
896
|
+
await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger);
|
|
897
|
+
}
|
|
898
|
+
} catch (err) {
|
|
899
|
+
logger?.warn?.("[approvals] afterInsert auto-trigger failed", { error: err?.message });
|
|
900
|
+
}
|
|
901
|
+
}, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 });
|
|
902
|
+
engine.registerHook("afterUpdate", async (ctx) => {
|
|
903
|
+
if (ctx?.session?.isSystem) return;
|
|
904
|
+
try {
|
|
905
|
+
const result = ctx?.result ?? {};
|
|
906
|
+
const id = String(ctx?.input?.id ?? result?.id ?? "");
|
|
907
|
+
if (!id) return;
|
|
908
|
+
const record = {
|
|
909
|
+
...ctx?.previous ?? {},
|
|
910
|
+
...result?.id ? result : {},
|
|
911
|
+
...ctx?.input?.data ?? {},
|
|
912
|
+
id
|
|
913
|
+
};
|
|
914
|
+
for (const proc of procs) {
|
|
915
|
+
await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger);
|
|
916
|
+
}
|
|
917
|
+
} catch (err) {
|
|
918
|
+
logger?.warn?.("[approvals] afterUpdate auto-trigger failed", { error: err?.message });
|
|
919
|
+
}
|
|
920
|
+
}, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 });
|
|
921
|
+
const lockProcs = procs.filter((p) => p.definition?.lockRecord !== false);
|
|
922
|
+
if (lockProcs.length === 0) continue;
|
|
923
|
+
engine.registerHook("beforeUpdate", async (ctx) => {
|
|
924
|
+
const id = String(ctx?.input?.id ?? "");
|
|
925
|
+
if (!id) return;
|
|
926
|
+
const data = ctx?.input?.data ?? {};
|
|
927
|
+
const changedFields = Object.keys(data).filter((k) => k !== "id" && k !== "updated_at");
|
|
928
|
+
if (changedFields.length === 0) return;
|
|
929
|
+
if (ctx?.session?.isSystem) return;
|
|
930
|
+
const mirrorFields = /* @__PURE__ */ new Set();
|
|
931
|
+
for (const p of lockProcs) {
|
|
932
|
+
const f = p.definition?.approvalStatusField;
|
|
933
|
+
if (typeof f === "string" && f) mirrorFields.add(f);
|
|
934
|
+
}
|
|
935
|
+
const onlyMirror = changedFields.every((f) => mirrorFields.has(f));
|
|
936
|
+
if (onlyMirror) return;
|
|
937
|
+
const roles = ctx?.session?.roles ?? [];
|
|
938
|
+
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
939
|
+
const pending = await hasPendingRequest(engine, objectName, id);
|
|
940
|
+
if (!pending) return;
|
|
941
|
+
const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
|
|
942
|
+
err.code = "RECORD_LOCKED";
|
|
943
|
+
err.statusCode = 409;
|
|
944
|
+
throw err;
|
|
945
|
+
}, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
946
|
+
}
|
|
947
|
+
logger?.info?.("[approvals] lifecycle hooks bound", {
|
|
948
|
+
objects: Array.from(byObject.keys()),
|
|
949
|
+
processCount: processes.length
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
function unbindAllHooks(engine) {
|
|
953
|
+
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
954
|
+
}
|
|
955
|
+
async function tryAutoSubmit(engine, service, process, objectName, recordId, record, ctx, logger) {
|
|
956
|
+
try {
|
|
957
|
+
const criteria = process.definition?.entryCriteria;
|
|
958
|
+
const passes = evaluateCriteria(criteria, record, logger);
|
|
959
|
+
if (!passes) return;
|
|
960
|
+
if (await hasPendingRequest(engine, objectName, recordId)) return;
|
|
961
|
+
const statusField = process.definition?.approvalStatusField;
|
|
962
|
+
if (statusField) {
|
|
963
|
+
const current = record?.[statusField];
|
|
964
|
+
if (current === "approved" || current === "rejected" || current === "recalled") return;
|
|
965
|
+
}
|
|
966
|
+
const submitterId = ctx?.session?.userId ?? null;
|
|
967
|
+
const submitterOrg = ctx?.session?.tenantId ?? ctx?.session?.organizationId ?? null;
|
|
968
|
+
await service.submit({
|
|
969
|
+
object: objectName,
|
|
970
|
+
recordId,
|
|
971
|
+
processName: process.name,
|
|
972
|
+
payload: record,
|
|
973
|
+
submitterId
|
|
974
|
+
}, { ...SYSTEM_CTX3, userId: submitterId ?? void 0, organizationId: submitterOrg ?? void 0, tenantId: submitterOrg ?? void 0 });
|
|
975
|
+
logger?.info?.("[approvals] auto-submitted approval", {
|
|
976
|
+
process: process.name,
|
|
977
|
+
object: objectName,
|
|
978
|
+
record: recordId
|
|
979
|
+
});
|
|
980
|
+
} catch (err) {
|
|
981
|
+
if (err?.code === "DUPLICATE_REQUEST") return;
|
|
982
|
+
logger?.warn?.("[approvals] auto-submit failed", {
|
|
983
|
+
process: process.name,
|
|
984
|
+
object: objectName,
|
|
985
|
+
record: recordId,
|
|
986
|
+
error: err?.message ?? String(err)
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// src/approvals-plugin.ts
|
|
992
|
+
var ApprovalsServicePlugin = class {
|
|
993
|
+
constructor(options = {}) {
|
|
994
|
+
this.name = "com.objectstack.service.approvals";
|
|
995
|
+
this.version = "1.0.0";
|
|
996
|
+
this.type = "standard";
|
|
997
|
+
this.dependencies = ["com.objectstack.engine.objectql"];
|
|
998
|
+
this.options = options;
|
|
999
|
+
}
|
|
1000
|
+
async init(ctx) {
|
|
1001
|
+
ctx.getService("manifest").register({
|
|
1002
|
+
id: "com.objectstack.service.approvals",
|
|
1003
|
+
name: "Approvals Service",
|
|
1004
|
+
version: "1.0.0",
|
|
1005
|
+
type: "plugin",
|
|
1006
|
+
scope: "system",
|
|
1007
|
+
defaultDatasource: "cloud",
|
|
1008
|
+
namespace: "sys",
|
|
1009
|
+
objects: [SysApprovalProcess, SysApprovalRequest, SysApprovalAction]
|
|
1010
|
+
});
|
|
1011
|
+
ctx.logger.info("ApprovalsServicePlugin: schemas registered");
|
|
1012
|
+
}
|
|
1013
|
+
async start(ctx) {
|
|
1014
|
+
if (this.options.disableService) return;
|
|
1015
|
+
let engine = null;
|
|
1016
|
+
try {
|
|
1017
|
+
engine = ctx.getService("objectql");
|
|
1018
|
+
} catch {
|
|
1019
|
+
try {
|
|
1020
|
+
engine = ctx.getService("data");
|
|
1021
|
+
} catch {
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
if (!engine) {
|
|
1025
|
+
ctx.logger.warn("ApprovalsServicePlugin: no ObjectQL engine \u2014 service NOT registered");
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
this.engine = engine;
|
|
1029
|
+
this.logger = ctx.logger;
|
|
1030
|
+
this.service = new ApprovalService({
|
|
1031
|
+
engine,
|
|
1032
|
+
logger: ctx.logger
|
|
1033
|
+
});
|
|
1034
|
+
if (!this.options.disableAutoHooks) {
|
|
1035
|
+
this.service.setRegistryChangeHandler(() => this.rebindHooks());
|
|
1036
|
+
const hookOn = ctx.hook ?? ctx.on;
|
|
1037
|
+
if (typeof hookOn === "function") {
|
|
1038
|
+
try {
|
|
1039
|
+
hookOn.call(ctx, "kernel:ready", async () => {
|
|
1040
|
+
await this.rebindHooks();
|
|
1041
|
+
});
|
|
1042
|
+
} catch {
|
|
1043
|
+
await this.rebindHooks();
|
|
1044
|
+
}
|
|
1045
|
+
} else {
|
|
1046
|
+
await this.rebindHooks();
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
ctx.registerService("approvals", this.service);
|
|
1050
|
+
ctx.logger.info("ApprovalsServicePlugin: service registered");
|
|
1051
|
+
}
|
|
1052
|
+
async rebindHooks() {
|
|
1053
|
+
if (!this.engine || !this.service) return;
|
|
1054
|
+
try {
|
|
1055
|
+
unbindAllHooks(this.engine);
|
|
1056
|
+
const processes = await this.service.listProcesses({ activeOnly: true }, { isSystem: true, roles: [], permissions: [] });
|
|
1057
|
+
bindProcessHooks(this.engine, this.service, processes, this.logger);
|
|
1058
|
+
} catch (err) {
|
|
1059
|
+
this.logger?.warn?.("[approvals] rebindHooks failed", { error: err?.message });
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
async stop(_ctx) {
|
|
1063
|
+
if (this.engine) {
|
|
1064
|
+
try {
|
|
1065
|
+
unbindAllHooks(this.engine);
|
|
1066
|
+
} catch {
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
export {
|
|
1072
|
+
ApprovalService,
|
|
1073
|
+
ApprovalsServicePlugin,
|
|
1074
|
+
SysApprovalAction2 as SysApprovalAction,
|
|
1075
|
+
SysApprovalProcess2 as SysApprovalProcess,
|
|
1076
|
+
SysApprovalRequest2 as SysApprovalRequest
|
|
1077
|
+
};
|
|
1078
|
+
//# sourceMappingURL=index.mjs.map
|