@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/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