@objectstack/plugin-approvals 7.3.0 → 7.4.0

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 CHANGED
@@ -1,207 +1,939 @@
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
- }
1
+ var __defProp = Object.defineProperty;
2
+ var __getOwnPropNames = Object.getOwnPropertyNames;
3
+ var __esm = (fn, res) => function __init() {
4
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
22
5
  };
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;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+
11
+ // src/translations/en.objects.generated.ts
12
+ var enObjects;
13
+ var init_en_objects_generated = __esm({
14
+ "src/translations/en.objects.generated.ts"() {
15
+ "use strict";
16
+ enObjects = {
17
+ sys_approval_request: {
18
+ label: "Approval Request",
19
+ pluralLabel: "Approval Requests",
20
+ description: "Live approval instance tracked per submission",
21
+ fields: {
22
+ id: {
23
+ label: "Request ID"
24
+ },
25
+ organization_id: {
26
+ label: "Organization",
27
+ help: "Tenant that owns this approval request (propagated from submitter context)"
28
+ },
29
+ process_name: {
30
+ label: "Source",
31
+ help: "Origin of the request \u2014 `flow:<flowName|nodeId>` for node-driven approvals"
32
+ },
33
+ object_name: {
34
+ label: "Object"
35
+ },
36
+ record_id: {
37
+ label: "Record ID"
38
+ },
39
+ submitter_id: {
40
+ label: "Submitter"
41
+ },
42
+ submitter_comment: {
43
+ label: "Submitter Comment"
44
+ },
45
+ status: {
46
+ label: "Status",
47
+ help: "Lifecycle state of the request",
48
+ options: {
49
+ pending: "pending",
50
+ approved: "approved",
51
+ rejected: "rejected",
52
+ recalled: "recalled"
53
+ }
54
+ },
55
+ current_step: {
56
+ label: "Current Step",
57
+ help: "Machine name of the step awaiting approval"
58
+ },
59
+ current_step_index: {
60
+ label: "Current Step Index"
61
+ },
62
+ pending_approvers: {
63
+ label: "Pending Approvers",
64
+ help: "Comma-separated user ids who can act on the current step"
65
+ },
66
+ payload_json: {
67
+ label: "Snapshot",
68
+ help: "Record snapshot at submission time"
69
+ },
70
+ flow_run_id: {
71
+ label: "Flow Run",
72
+ help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
73
+ },
74
+ flow_node_id: {
75
+ label: "Flow Node",
76
+ help: "Approval node id within the flow that opened this request (ADR-0019)."
77
+ },
78
+ node_config_json: {
79
+ label: "Node Config",
80
+ help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
81
+ },
82
+ completed_at: {
83
+ label: "Completed At"
84
+ },
85
+ created_at: {
86
+ label: "Created At"
87
+ },
88
+ updated_at: {
89
+ label: "Updated At"
90
+ }
91
+ },
92
+ _views: {
93
+ my_pending: {
94
+ label: "My Pending"
95
+ },
96
+ submitted_by_me: {
97
+ label: "I Submitted"
98
+ },
99
+ completed: {
100
+ label: "Completed"
101
+ },
102
+ all_requests: {
103
+ label: "All"
104
+ }
105
+ }
106
+ },
107
+ sys_approval_action: {
108
+ label: "Approval Action",
109
+ pluralLabel: "Approval Actions",
110
+ description: "Append-only audit trail for approval actions",
111
+ fields: {
112
+ id: {
113
+ label: "Action ID"
114
+ },
115
+ organization_id: {
116
+ label: "Organization",
117
+ help: "Tenant that owns this action (mirrors the parent request)"
118
+ },
119
+ request_id: {
120
+ label: "Request"
121
+ },
122
+ step_name: {
123
+ label: "Step",
124
+ help: "Machine name of the step at the time of the action"
125
+ },
126
+ step_index: {
127
+ label: "Step Index"
128
+ },
129
+ action: {
130
+ label: "Action",
131
+ options: {
132
+ submit: "submit",
133
+ approve: "approve",
134
+ reject: "reject",
135
+ recall: "recall",
136
+ escalate: "escalate"
137
+ }
138
+ },
139
+ actor_id: {
140
+ label: "Actor"
141
+ },
142
+ comment: {
143
+ label: "Comment"
144
+ },
145
+ created_at: {
146
+ label: "Created At"
147
+ }
148
+ },
149
+ _views: {
150
+ recent: {
151
+ label: "Recent"
152
+ },
153
+ by_actor: {
154
+ label: "By Actor"
155
+ },
156
+ all_actions: {
157
+ label: "All"
158
+ }
159
+ }
160
+ }
161
+ };
110
162
  }
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
163
+ });
164
+
165
+ // src/translations/zh-CN.objects.generated.ts
166
+ var zhCNObjects;
167
+ var init_zh_CN_objects_generated = __esm({
168
+ "src/translations/zh-CN.objects.generated.ts"() {
169
+ "use strict";
170
+ zhCNObjects = {
171
+ sys_approval_request: {
172
+ label: "\u5BA1\u6279\u8BF7\u6C42",
173
+ pluralLabel: "\u5BA1\u6279\u8BF7\u6C42",
174
+ description: "\u6309\u63D0\u4EA4\u8BB0\u5F55\u8DDF\u8E2A\u7684\u5B9E\u65F6\u5BA1\u6279\u5B9E\u4F8B",
175
+ fields: {
176
+ id: {
177
+ label: "\u8BF7\u6C42 ID"
178
+ },
179
+ organization_id: {
180
+ label: "\u7EC4\u7EC7",
181
+ help: "\u62E5\u6709\u8BE5\u5BA1\u6279\u8BF7\u6C42\u7684\u79DF\u6237\uFF08\u4ECE\u63D0\u4EA4\u65B9\u4E0A\u4E0B\u6587\u4F20\u64AD\uFF09"
182
+ },
183
+ process_name: {
184
+ label: "\u6765\u6E90",
185
+ help: "\u8BF7\u6C42\u6765\u6E90 \u2014\u2014 \u8282\u70B9\u9A71\u52A8\u7684\u5BA1\u6279\u4E3A `flow:<flowName|nodeId>`"
186
+ },
187
+ object_name: {
188
+ label: "\u5BF9\u8C61"
189
+ },
190
+ record_id: {
191
+ label: "\u8BB0\u5F55 ID"
192
+ },
193
+ submitter_id: {
194
+ label: "\u63D0\u4EA4\u4EBA"
195
+ },
196
+ submitter_comment: {
197
+ label: "\u63D0\u4EA4\u5907\u6CE8"
198
+ },
199
+ status: {
200
+ label: "\u72B6\u6001",
201
+ help: "\u8BF7\u6C42\u7684\u751F\u547D\u5468\u671F\u72B6\u6001",
202
+ options: {
203
+ pending: "\u5F85\u5904\u7406",
204
+ approved: "\u5DF2\u6279\u51C6",
205
+ rejected: "\u5DF2\u62D2\u7EDD",
206
+ recalled: "\u5DF2\u64A4\u56DE"
207
+ }
208
+ },
209
+ current_step: {
210
+ label: "\u5F53\u524D\u6B65\u9AA4",
211
+ help: "\u5F53\u524D\u7B49\u5F85\u5BA1\u6279\u7684\u6B65\u9AA4\u673A\u5668\u540D\u79F0"
212
+ },
213
+ current_step_index: {
214
+ label: "\u5F53\u524D\u6B65\u9AA4\u7D22\u5F15"
215
+ },
216
+ pending_approvers: {
217
+ label: "\u5F85\u5BA1\u6279\u4EBA",
218
+ help: "\u53EF\u5728\u5F53\u524D\u6B65\u9AA4\u6267\u884C\u64CD\u4F5C\u7684\u7528\u6237 ID\uFF0C\u9017\u53F7\u5206\u9694"
219
+ },
220
+ payload_json: {
221
+ label: "\u5FEB\u7167",
222
+ help: "\u63D0\u4EA4\u65F6\u7684\u8BB0\u5F55\u5FEB\u7167"
223
+ },
224
+ flow_run_id: {
225
+ label: "Flow Run",
226
+ help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
227
+ },
228
+ flow_node_id: {
229
+ label: "Flow Node",
230
+ help: "Approval node id within the flow that opened this request (ADR-0019)."
231
+ },
232
+ node_config_json: {
233
+ label: "Node Config",
234
+ help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
235
+ },
236
+ completed_at: {
237
+ label: "\u5B8C\u6210\u65F6\u95F4"
238
+ },
239
+ created_at: {
240
+ label: "\u521B\u5EFA\u65F6\u95F4"
241
+ },
242
+ updated_at: {
243
+ label: "\u66F4\u65B0\u65F6\u95F4"
244
+ }
133
245
  },
134
- { context: SYSTEM_CTX }
135
- );
136
- } catch (err) {
137
- log.warn?.(`[approvals] inbox_notify insert failed for ${recipient}: ${err?.message ?? err}`);
138
- }
246
+ _views: {
247
+ my_pending: {
248
+ label: "\u6211\u7684\u5F85\u529E"
249
+ },
250
+ submitted_by_me: {
251
+ label: "\u6211\u63D0\u4EA4\u7684"
252
+ },
253
+ completed: {
254
+ label: "\u5DF2\u5B8C\u6210"
255
+ },
256
+ all_requests: {
257
+ label: "\u5168\u90E8"
258
+ }
259
+ }
260
+ },
261
+ sys_approval_action: {
262
+ label: "\u5BA1\u6279\u52A8\u4F5C",
263
+ pluralLabel: "\u5BA1\u6279\u52A8\u4F5C",
264
+ description: "\u8FFD\u52A0\u5199\u5165\u7684\u5BA1\u6279\u64CD\u4F5C\u5BA1\u8BA1\u8BB0\u5F55",
265
+ fields: {
266
+ id: {
267
+ label: "\u52A8\u4F5C ID"
268
+ },
269
+ organization_id: {
270
+ label: "\u7EC4\u7EC7",
271
+ help: "\u62E5\u6709\u8BE5\u52A8\u4F5C\u7684\u79DF\u6237\uFF08\u4E0E\u7236\u8BF7\u6C42\u4FDD\u6301\u4E00\u81F4\uFF09"
272
+ },
273
+ request_id: {
274
+ label: "\u8BF7\u6C42"
275
+ },
276
+ step_name: {
277
+ label: "\u6B65\u9AA4",
278
+ help: "\u6267\u884C\u8BE5\u52A8\u4F5C\u65F6\u5BF9\u5E94\u6B65\u9AA4\u7684\u673A\u5668\u540D\u79F0"
279
+ },
280
+ step_index: {
281
+ label: "\u6B65\u9AA4\u7D22\u5F15"
282
+ },
283
+ action: {
284
+ label: "\u64CD\u4F5C",
285
+ options: {
286
+ submit: "\u63D0\u4EA4",
287
+ approve: "\u6279\u51C6",
288
+ reject: "\u62D2\u7EDD",
289
+ recall: "\u64A4\u56DE",
290
+ escalate: "\u5347\u7EA7"
291
+ }
292
+ },
293
+ actor_id: {
294
+ label: "\u6267\u884C\u4EBA"
295
+ },
296
+ comment: {
297
+ label: "\u8BC4\u8BBA"
298
+ },
299
+ created_at: {
300
+ label: "\u521B\u5EFA\u65F6\u95F4"
301
+ }
302
+ },
303
+ _views: {
304
+ recent: {
305
+ label: "\u6700\u8FD1"
306
+ },
307
+ by_actor: {
308
+ label: "\u6309\u6267\u884C\u4EBA"
309
+ },
310
+ all_actions: {
311
+ label: "\u5168\u90E8"
312
+ }
313
+ }
314
+ }
315
+ };
139
316
  }
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];
317
+ });
318
+
319
+ // src/translations/ja-JP.objects.generated.ts
320
+ var jaJPObjects;
321
+ var init_ja_JP_objects_generated = __esm({
322
+ "src/translations/ja-JP.objects.generated.ts"() {
323
+ "use strict";
324
+ jaJPObjects = {
325
+ sys_approval_request: {
326
+ label: "\u627F\u8A8D\u30EA\u30AF\u30A8\u30B9\u30C8",
327
+ pluralLabel: "\u627F\u8A8D\u30EA\u30AF\u30A8\u30B9\u30C8",
328
+ description: "\u9001\u4FE1\u3054\u3068\u306B\u8FFD\u8DE1\u3055\u308C\u308B\u30E9\u30A4\u30D6\u627F\u8A8D\u30A4\u30F3\u30B9\u30BF\u30F3\u30B9",
329
+ fields: {
330
+ id: {
331
+ label: "\u30EA\u30AF\u30A8\u30B9\u30C8 ID"
332
+ },
333
+ organization_id: {
334
+ label: "\u7D44\u7E54",
335
+ help: "\u3053\u306E\u627F\u8A8D\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u6240\u6709\u3059\u308B\u30C6\u30CA\u30F3\u30C8\uFF08\u9001\u4FE1\u8005\u30B3\u30F3\u30C6\u30AD\u30B9\u30C8\u304B\u3089\u4F1D\u64AD\uFF09"
336
+ },
337
+ process_name: {
338
+ label: "\u30BD\u30FC\u30B9",
339
+ help: "\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u767A\u751F\u5143 \u2014 \u30CE\u30FC\u30C9\u99C6\u52D5\u306E\u627F\u8A8D\u3067\u306F `flow:<flowName|nodeId>`"
340
+ },
341
+ object_name: {
342
+ label: "\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8"
343
+ },
344
+ record_id: {
345
+ label: "\u30EC\u30B3\u30FC\u30C9 ID"
346
+ },
347
+ submitter_id: {
348
+ label: "\u9001\u4FE1\u8005"
349
+ },
350
+ submitter_comment: {
351
+ label: "\u9001\u4FE1\u8005\u30B3\u30E1\u30F3\u30C8"
352
+ },
353
+ status: {
354
+ label: "\u30B9\u30C6\u30FC\u30BF\u30B9",
355
+ help: "\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u30E9\u30A4\u30D5\u30B5\u30A4\u30AF\u30EB\u72B6\u614B",
356
+ options: {
357
+ pending: "\u4FDD\u7559\u4E2D",
358
+ approved: "\u627F\u8A8D\u6E08\u307F",
359
+ rejected: "\u5374\u4E0B\u6E08\u307F",
360
+ recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
361
+ }
362
+ },
363
+ current_step: {
364
+ label: "\u73FE\u5728\u306E\u30B9\u30C6\u30C3\u30D7",
365
+ help: "\u627F\u8A8D\u5F85\u3061\u306E\u30B9\u30C6\u30C3\u30D7\u306E\u6A5F\u68B0\u540D"
366
+ },
367
+ current_step_index: {
368
+ label: "\u73FE\u5728\u306E\u30B9\u30C6\u30C3\u30D7\u756A\u53F7"
369
+ },
370
+ pending_approvers: {
371
+ label: "\u627F\u8A8D\u5F85\u3061\u627F\u8A8D\u8005",
372
+ help: "\u73FE\u5728\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u51E6\u7406\u3067\u304D\u308B\u30E6\u30FC\u30B6\u30FC ID \u306E\u30AB\u30F3\u30DE\u533A\u5207\u308A\u30EA\u30B9\u30C8"
373
+ },
374
+ payload_json: {
375
+ label: "\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8",
376
+ help: "\u9001\u4FE1\u6642\u306E\u30EC\u30B3\u30FC\u30C9\u30B9\u30CA\u30C3\u30D7\u30B7\u30E7\u30C3\u30C8"
377
+ },
378
+ flow_run_id: {
379
+ label: "Flow Run",
380
+ help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
381
+ },
382
+ flow_node_id: {
383
+ label: "Flow Node",
384
+ help: "Approval node id within the flow that opened this request (ADR-0019)."
385
+ },
386
+ node_config_json: {
387
+ label: "Node Config",
388
+ help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
389
+ },
390
+ completed_at: {
391
+ label: "\u5B8C\u4E86\u65E5\u6642"
392
+ },
393
+ created_at: {
394
+ label: "\u4F5C\u6210\u65E5\u6642"
395
+ },
396
+ updated_at: {
397
+ label: "\u66F4\u65B0\u65E5\u6642"
398
+ }
399
+ },
400
+ _views: {
401
+ my_pending: {
402
+ label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
403
+ },
404
+ submitted_by_me: {
405
+ label: "\u81EA\u5206\u304C\u9001\u4FE1"
406
+ },
407
+ completed: {
408
+ label: "\u5B8C\u4E86\u6E08\u307F"
409
+ },
410
+ all_requests: {
411
+ label: "\u3059\u3079\u3066"
412
+ }
413
+ }
414
+ },
415
+ sys_approval_action: {
416
+ label: "\u627F\u8A8D\u30A2\u30AF\u30B7\u30E7\u30F3",
417
+ pluralLabel: "\u627F\u8A8D\u30A2\u30AF\u30B7\u30E7\u30F3",
418
+ description: "\u627F\u8A8D\u30A2\u30AF\u30B7\u30E7\u30F3\u306E\u8FFD\u8A18\u5C02\u7528\u76E3\u67FB\u8A3C\u8DE1",
419
+ fields: {
420
+ id: {
421
+ label: "\u30A2\u30AF\u30B7\u30E7\u30F3 ID"
422
+ },
423
+ organization_id: {
424
+ label: "\u7D44\u7E54",
425
+ help: "\u3053\u306E\u30A2\u30AF\u30B7\u30E7\u30F3\u3092\u6240\u6709\u3059\u308B\u30C6\u30CA\u30F3\u30C8\uFF08\u89AA\u30EA\u30AF\u30A8\u30B9\u30C8\u3068\u540C\u3058\uFF09"
426
+ },
427
+ request_id: {
428
+ label: "\u30EA\u30AF\u30A8\u30B9\u30C8"
429
+ },
430
+ step_name: {
431
+ label: "\u30B9\u30C6\u30C3\u30D7",
432
+ help: "\u30A2\u30AF\u30B7\u30E7\u30F3\u6642\u70B9\u306E\u30B9\u30C6\u30C3\u30D7\u306E\u6A5F\u68B0\u540D"
433
+ },
434
+ step_index: {
435
+ label: "\u30B9\u30C6\u30C3\u30D7\u756A\u53F7"
436
+ },
437
+ action: {
438
+ label: "\u30A2\u30AF\u30B7\u30E7\u30F3",
439
+ options: {
440
+ submit: "\u7533\u8ACB",
441
+ approve: "\u627F\u8A8D",
442
+ reject: "\u5374\u4E0B",
443
+ recall: "\u53D6\u6D88",
444
+ escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
445
+ }
446
+ },
447
+ actor_id: {
448
+ label: "\u64CD\u4F5C\u8005"
449
+ },
450
+ comment: {
451
+ label: "\u30B3\u30E1\u30F3\u30C8"
452
+ },
453
+ created_at: {
454
+ label: "\u4F5C\u6210\u65E5\u6642"
455
+ }
456
+ },
457
+ _views: {
458
+ recent: {
459
+ label: "\u6700\u8FD1"
460
+ },
461
+ by_actor: {
462
+ label: "\u64CD\u4F5C\u8005\u5225"
463
+ },
464
+ all_actions: {
465
+ label: "\u3059\u3079\u3066"
466
+ }
467
+ }
468
+ }
469
+ };
152
470
  }
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;
471
+ });
472
+
473
+ // src/translations/es-ES.objects.generated.ts
474
+ var esESObjects;
475
+ var init_es_ES_objects_generated = __esm({
476
+ "src/translations/es-ES.objects.generated.ts"() {
477
+ "use strict";
478
+ esESObjects = {
479
+ sys_approval_request: {
480
+ label: "Solicitud de aprobaci\xF3n",
481
+ pluralLabel: "Solicitudes de aprobaci\xF3n",
482
+ description: "Instancia activa de aprobaci\xF3n registrada por env\xEDo",
483
+ fields: {
484
+ id: {
485
+ label: "ID de solicitud"
486
+ },
487
+ organization_id: {
488
+ label: "Organizaci\xF3n",
489
+ help: "Tenant que posee esta solicitud de aprobaci\xF3n (propagado desde el contexto del solicitante)."
490
+ },
491
+ process_name: {
492
+ label: "Origen",
493
+ help: "Origen de la solicitud \u2014 `flow:<flowName|nodeId>` para aprobaciones por nodo"
494
+ },
495
+ object_name: {
496
+ label: "Objeto"
497
+ },
498
+ record_id: {
499
+ label: "ID de registro"
500
+ },
501
+ submitter_id: {
502
+ label: "Solicitante"
503
+ },
504
+ submitter_comment: {
505
+ label: "Comentario del solicitante"
506
+ },
507
+ status: {
508
+ label: "Estado",
509
+ help: "Estado del ciclo de vida de la solicitud.",
510
+ options: {
511
+ pending: "Pendiente",
512
+ approved: "Aprobada",
513
+ rejected: "Rechazada",
514
+ recalled: "Retirada"
515
+ }
516
+ },
517
+ current_step: {
518
+ label: "Paso actual",
519
+ help: "Nombre t\xE9cnico del paso pendiente de aprobaci\xF3n."
520
+ },
521
+ current_step_index: {
522
+ label: "\xCDndice del paso actual"
523
+ },
524
+ pending_approvers: {
525
+ label: "Aprobadores pendientes",
526
+ help: "ID de usuario separados por comas que pueden actuar en el paso actual."
527
+ },
528
+ payload_json: {
529
+ label: "Instant\xE1nea",
530
+ help: "Instant\xE1nea del registro en el momento del env\xEDo."
531
+ },
532
+ flow_run_id: {
533
+ label: "Flow Run",
534
+ help: "Suspended automation run id this request gates (ADR-0019). The decision resumes it."
535
+ },
536
+ flow_node_id: {
537
+ label: "Flow Node",
538
+ help: "Approval node id within the flow that opened this request (ADR-0019)."
539
+ },
540
+ node_config_json: {
541
+ label: "Node Config",
542
+ help: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019)."
543
+ },
544
+ completed_at: {
545
+ label: "Completado el"
546
+ },
547
+ created_at: {
548
+ label: "Creado el"
549
+ },
550
+ updated_at: {
551
+ label: "Actualizado el"
552
+ }
553
+ },
554
+ _views: {
555
+ my_pending: {
556
+ label: "Mis pendientes"
557
+ },
558
+ submitted_by_me: {
559
+ label: "Enviadas por m\xED"
560
+ },
561
+ completed: {
562
+ label: "Completadas"
563
+ },
564
+ all_requests: {
565
+ label: "Todas"
566
+ }
567
+ }
568
+ },
569
+ sys_approval_action: {
570
+ label: "Acci\xF3n de aprobaci\xF3n",
571
+ pluralLabel: "Acciones de aprobaci\xF3n",
572
+ description: "Registro de auditor\xEDa append-only para acciones de aprobaci\xF3n",
573
+ fields: {
574
+ id: {
575
+ label: "ID de acci\xF3n"
576
+ },
577
+ organization_id: {
578
+ label: "Organizaci\xF3n",
579
+ help: "Tenant que posee esta acci\xF3n (refleja la solicitud principal)."
580
+ },
581
+ request_id: {
582
+ label: "Solicitud"
583
+ },
584
+ step_name: {
585
+ label: "Paso",
586
+ help: "Nombre t\xE9cnico del paso en el momento de la acci\xF3n."
587
+ },
588
+ step_index: {
589
+ label: "\xCDndice del paso"
590
+ },
591
+ action: {
592
+ label: "Acci\xF3n",
593
+ options: {
594
+ submit: "Enviar",
595
+ approve: "Aprobar",
596
+ reject: "Rechazar",
597
+ recall: "Retirar",
598
+ escalate: "Escalar"
599
+ }
600
+ },
601
+ actor_id: {
602
+ label: "Actor"
603
+ },
604
+ comment: {
605
+ label: "Comentario"
606
+ },
607
+ created_at: {
608
+ label: "Creado el"
609
+ }
610
+ },
611
+ _views: {
612
+ recent: {
613
+ label: "Recientes"
614
+ },
615
+ by_actor: {
616
+ label: "Por actor"
617
+ },
618
+ all_actions: {
619
+ label: "Todas"
620
+ }
621
+ }
622
+ }
623
+ };
166
624
  }
167
- const fetchImpl = opts.fetch ?? globalThis.fetch;
168
- if (!fetchImpl) {
169
- log.warn?.("[approvals] webhook skipped \u2014 no fetch implementation available");
170
- return;
625
+ });
626
+
627
+ // src/translations/index.ts
628
+ var translations_exports = {};
629
+ __export(translations_exports, {
630
+ ApprovalsTranslations: () => ApprovalsTranslations
631
+ });
632
+ var ApprovalsTranslations;
633
+ var init_translations = __esm({
634
+ "src/translations/index.ts"() {
635
+ "use strict";
636
+ init_en_objects_generated();
637
+ init_zh_CN_objects_generated();
638
+ init_ja_JP_objects_generated();
639
+ init_es_ES_objects_generated();
640
+ ApprovalsTranslations = {
641
+ en: { objects: enObjects },
642
+ "zh-CN": { objects: zhCNObjects },
643
+ "ja-JP": { objects: jaJPObjects },
644
+ "es-ES": { objects: esESObjects }
645
+ };
171
646
  }
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}`);
647
+ });
648
+
649
+ // src/sys-approval-request.object.ts
650
+ import { ObjectSchema, Field } from "@objectstack/spec/data";
651
+ var SysApprovalRequest = ObjectSchema.create({
652
+ name: "sys_approval_request",
653
+ label: "Approval Request",
654
+ pluralLabel: "Approval Requests",
655
+ icon: "inbox",
656
+ isSystem: true,
657
+ managedBy: "system",
658
+ description: "Live approval instance tracked per submission",
659
+ displayNameField: "id",
660
+ titleFormat: "{process_name} \xB7 {record_id}",
661
+ compactLayout: ["process_name", "object_name", "record_id", "status", "current_step", "submitter_id", "updated_at"],
662
+ // Curated built-in list views — render as segmented tabs in the console.
663
+ // Filters use {current_user_id} substitution wired by the console.
664
+ listViews: {
665
+ my_pending: {
666
+ type: "grid",
667
+ name: "my_pending",
668
+ label: "My Pending",
669
+ data: { provider: "object", object: "sys_approval_request" },
670
+ columns: ["process_name", "object_name", "record_id", "current_step", "submitter_id", "updated_at"],
671
+ filter: [
672
+ { field: "status", operator: "equals", value: "pending" },
673
+ { field: "pending_approvers", operator: "contains", value: "{current_user_id}" }
674
+ ],
675
+ sort: [{ field: "updated_at", order: "desc" }],
676
+ pagination: { pageSize: 25 },
677
+ emptyState: { title: "No pending approvals", message: "You're all caught up." }
678
+ },
679
+ submitted_by_me: {
680
+ type: "grid",
681
+ name: "submitted_by_me",
682
+ label: "I Submitted",
683
+ data: { provider: "object", object: "sys_approval_request" },
684
+ columns: ["process_name", "object_name", "record_id", "status", "current_step", "updated_at"],
685
+ filter: [{ field: "submitter_id", operator: "equals", value: "{current_user_id}" }],
686
+ sort: [{ field: "updated_at", order: "desc" }],
687
+ pagination: { pageSize: 25 }
688
+ },
689
+ completed: {
690
+ type: "grid",
691
+ name: "completed",
692
+ label: "Completed",
693
+ data: { provider: "object", object: "sys_approval_request" },
694
+ columns: ["process_name", "object_name", "record_id", "status", "submitter_id", "completed_at"],
695
+ filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled"] }],
696
+ sort: [{ field: "completed_at", order: "desc" }],
697
+ pagination: { pageSize: 25 }
698
+ },
699
+ all_requests: {
700
+ type: "grid",
701
+ name: "all_requests",
702
+ label: "All",
703
+ data: { provider: "object", object: "sys_approval_request" },
704
+ columns: ["process_name", "object_name", "record_id", "status", "current_step", "submitter_id", "updated_at"],
705
+ sort: [{ field: "updated_at", order: "desc" }],
706
+ pagination: { pageSize: 50 }
195
707
  }
196
- } catch (err) {
197
- log.warn?.(`[approvals] webhook ${url} failed: ${err?.message ?? err}`);
198
- } finally {
199
- clearTimeout(timer);
200
- }
201
- }
708
+ },
709
+ fields: {
710
+ id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
711
+ organization_id: Field.lookup("sys_organization", {
712
+ label: "Organization",
713
+ required: false,
714
+ group: "System",
715
+ description: "Tenant that owns this approval request (propagated from submitter context)"
716
+ }),
717
+ process_name: Field.text({
718
+ label: "Source",
719
+ required: true,
720
+ maxLength: 100,
721
+ description: "Origin of the request \u2014 `flow:<flowName|nodeId>` for node-driven approvals",
722
+ group: "Target"
723
+ }),
724
+ object_name: Field.text({
725
+ label: "Object",
726
+ required: true,
727
+ maxLength: 100,
728
+ group: "Target"
729
+ }),
730
+ record_id: Field.text({
731
+ label: "Record ID",
732
+ required: true,
733
+ maxLength: 100,
734
+ group: "Target"
735
+ }),
736
+ submitter_id: Field.lookup("sys_user", {
737
+ label: "Submitter",
738
+ required: false,
739
+ group: "Target"
740
+ }),
741
+ submitter_comment: Field.textarea({
742
+ label: "Submitter Comment",
743
+ required: false,
744
+ group: "Target"
745
+ }),
746
+ status: Field.select(
747
+ ["pending", "approved", "rejected", "recalled"],
748
+ {
749
+ label: "Status",
750
+ required: true,
751
+ defaultValue: "pending",
752
+ description: "Lifecycle state of the request",
753
+ group: "State"
754
+ }
755
+ ),
756
+ current_step: Field.text({
757
+ label: "Current Step",
758
+ required: false,
759
+ maxLength: 100,
760
+ description: "Machine name of the step awaiting approval",
761
+ group: "State"
762
+ }),
763
+ current_step_index: Field.number({
764
+ label: "Current Step Index",
765
+ required: false,
766
+ defaultValue: 0,
767
+ group: "State"
768
+ }),
769
+ pending_approvers: Field.textarea({
770
+ label: "Pending Approvers",
771
+ required: false,
772
+ description: "Comma-separated user ids who can act on the current step",
773
+ group: "State"
774
+ }),
775
+ payload_json: Field.textarea({
776
+ label: "Snapshot",
777
+ required: false,
778
+ description: "Record snapshot at submission time",
779
+ group: "State"
780
+ }),
781
+ // ── ADR-0019: approval-as-flow-node correlation ──────────────────
782
+ // When a request is opened by an Approval *node* (rather than a standalone
783
+ // process), these tie it back to the suspended flow run so a decision can
784
+ // resume it. Null for legacy process-driven requests.
785
+ flow_run_id: Field.text({
786
+ label: "Flow Run",
787
+ required: false,
788
+ maxLength: 100,
789
+ readonly: true,
790
+ description: "Suspended automation run id this request gates (ADR-0019). The decision resumes it.",
791
+ group: "State"
792
+ }),
793
+ flow_node_id: Field.text({
794
+ label: "Flow Node",
795
+ required: false,
796
+ maxLength: 100,
797
+ readonly: true,
798
+ description: "Approval node id within the flow that opened this request (ADR-0019).",
799
+ group: "State"
800
+ }),
801
+ node_config_json: Field.textarea({
802
+ label: "Node Config",
803
+ required: false,
804
+ readonly: true,
805
+ description: "Snapshot of the Approval node config (approvers/behavior) for node-driven requests (ADR-0019).",
806
+ group: "State"
807
+ }),
808
+ completed_at: Field.datetime({
809
+ label: "Completed At",
810
+ required: false,
811
+ group: "State"
812
+ }),
813
+ created_at: Field.datetime({
814
+ label: "Created At",
815
+ required: true,
816
+ defaultValue: "NOW()",
817
+ readonly: true,
818
+ group: "System"
819
+ }),
820
+ updated_at: Field.datetime({ label: "Updated At", required: false, group: "System" })
821
+ },
822
+ indexes: [
823
+ // Look up "is there a pending request for this record?" — common
824
+ // guard on submit and on edit-while-locked checks.
825
+ { fields: ["object_name", "record_id"] },
826
+ { fields: ["status", "object_name"] },
827
+ // "My approvals" inbox — pending_approvers is a CSV string so this
828
+ // index only helps with status pre-filtering; the engine does a
829
+ // post-filter substring match per row.
830
+ { fields: ["status", "updated_at"] },
831
+ { fields: ["submitter_id", "status"] }
832
+ ]
833
+ });
834
+
835
+ // src/sys-approval-action.object.ts
836
+ import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
837
+ var SysApprovalAction = ObjectSchema2.create({
838
+ name: "sys_approval_action",
839
+ label: "Approval Action",
840
+ pluralLabel: "Approval Actions",
841
+ icon: "check-circle",
842
+ isSystem: true,
843
+ managedBy: "append-only",
844
+ description: "Append-only audit trail for approval actions",
845
+ displayNameField: "id",
846
+ titleFormat: "{action} \xB7 {step_name}",
847
+ compactLayout: ["request_id", "step_name", "action", "actor_id", "created_at"],
848
+ listViews: {
849
+ recent: {
850
+ type: "grid",
851
+ name: "recent",
852
+ label: "Recent",
853
+ data: { provider: "object", object: "sys_approval_action" },
854
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
855
+ sort: [{ field: "created_at", order: "desc" }],
856
+ pagination: { pageSize: 50 },
857
+ emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
858
+ },
859
+ by_actor: {
860
+ type: "grid",
861
+ name: "by_actor",
862
+ label: "By Actor",
863
+ data: { provider: "object", object: "sys_approval_action" },
864
+ columns: ["actor_id", "created_at", "request_id", "step_name", "action"],
865
+ sort: [{ field: "actor_id", order: "asc" }, { field: "created_at", order: "desc" }],
866
+ grouping: { fields: [{ field: "actor_id", order: "asc", collapsed: false }] },
867
+ pagination: { pageSize: 100 }
868
+ },
869
+ all_actions: {
870
+ type: "grid",
871
+ name: "all_actions",
872
+ label: "All",
873
+ data: { provider: "object", object: "sys_approval_action" },
874
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
875
+ sort: [{ field: "created_at", order: "desc" }],
876
+ pagination: { pageSize: 100 }
877
+ }
878
+ },
879
+ fields: {
880
+ id: Field2.text({ label: "Action ID", required: true, readonly: true, group: "System" }),
881
+ organization_id: Field2.lookup("sys_organization", {
882
+ label: "Organization",
883
+ required: false,
884
+ group: "System",
885
+ description: "Tenant that owns this action (mirrors the parent request)"
886
+ }),
887
+ request_id: Field2.lookup("sys_approval_request", {
888
+ label: "Request",
889
+ required: true,
890
+ group: "Target"
891
+ }),
892
+ step_name: Field2.text({
893
+ label: "Step",
894
+ required: false,
895
+ maxLength: 100,
896
+ description: "Machine name of the step at the time of the action",
897
+ group: "Target"
898
+ }),
899
+ step_index: Field2.number({
900
+ label: "Step Index",
901
+ required: false,
902
+ group: "Target"
903
+ }),
904
+ action: Field2.select(
905
+ ["submit", "approve", "reject", "recall", "escalate"],
906
+ {
907
+ label: "Action",
908
+ required: true,
909
+ group: "Action"
910
+ }
911
+ ),
912
+ actor_id: Field2.lookup("sys_user", {
913
+ label: "Actor",
914
+ required: false,
915
+ group: "Action"
916
+ }),
917
+ comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
918
+ created_at: Field2.datetime({
919
+ label: "Created At",
920
+ required: true,
921
+ defaultValue: "NOW()",
922
+ readonly: true,
923
+ group: "System"
924
+ })
925
+ },
926
+ indexes: [
927
+ { fields: ["request_id", "created_at"] },
928
+ { fields: ["request_id", "step_index", "action"] }
929
+ ]
930
+ });
202
931
 
203
932
  // src/approval-service.ts
204
- var SYSTEM_CTX2 = { isSystem: true, roles: [], permissions: [] };
933
+ import {
934
+ APPROVAL_BRANCH_LABELS
935
+ } from "@objectstack/spec/automation";
936
+ var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
205
937
  function uid(prefix) {
206
938
  const g = globalThis;
207
939
  if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -223,25 +955,11 @@ function csvSplit(raw) {
223
955
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
224
956
  return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
225
957
  }
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
958
  function rowFromRequest(row) {
240
959
  return {
241
960
  id: String(row.id),
242
961
  organization_id: row.organization_id ?? void 0,
243
962
  process_name: String(row.process_name ?? ""),
244
- process_hash: row.process_hash ?? void 0,
245
963
  object_name: String(row.object_name ?? ""),
246
964
  record_id: String(row.record_id ?? ""),
247
965
  submitter_id: row.submitter_id ?? void 0,
@@ -251,6 +969,8 @@ function rowFromRequest(row) {
251
969
  current_step_index: row.current_step_index ?? void 0,
252
970
  pending_approvers: csvSplit(row.pending_approvers),
253
971
  payload: parseJson(row.payload_json, void 0),
972
+ flow_run_id: row.flow_run_id ?? void 0,
973
+ flow_node_id: row.flow_node_id ?? void 0,
254
974
  completed_at: row.completed_at ?? void 0,
255
975
  created_at: row.created_at ?? void 0,
256
976
  updated_at: row.updated_at ?? void 0
@@ -273,23 +993,20 @@ var ApprovalService = class {
273
993
  this.engine = opts.engine;
274
994
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
275
995
  this.logger = opts.logger;
276
- this.fetchImpl = opts.fetch;
277
- this.webhookTimeoutMs = opts.webhookTimeoutMs;
278
- this.onRegistryChange = opts.onRegistryChange;
279
- this.metadataRepo = opts.metadataRepo;
996
+ this.automation = opts.automation;
280
997
  }
281
- /** Allow the plugin to attach a hook re-binding callback after construction. */
282
- setRegistryChangeHandler(handler) {
283
- this.onRegistryChange = handler;
998
+ /** Attach (or replace) the automation surface used to resume flow runs. */
999
+ attachAutomation(automation) {
1000
+ this.automation = automation;
284
1001
  }
285
1002
  /**
286
- * Expand the approvers on a step into user IDs by querying the graph
287
- * tables for `team:` / `department:` / `role:` / `manager:` approver
288
- * types. Falls back to a prefixed literal (`type:value`) when graph
289
- * lookups produce nothing — so existing test fixtures and approver
290
- * flows that rely on substring matching keep working.
1003
+ * Expand the approvers on an Approval node into user IDs by querying the
1004
+ * graph tables for `team:` / `department:` / `role:` / `manager:` approver
1005
+ * types. Falls back to a prefixed literal (`type:value`) when graph lookups
1006
+ * produce nothing — so existing fixtures and flows that rely on substring
1007
+ * matching keep working.
291
1008
  *
292
- * **Graph semantics (M10.17.1):**
1009
+ * **Graph semantics:**
293
1010
  * - `team` → flat members of `sys_team` (better-auth; no BFS)
294
1011
  * - `department` → recursive BFS of `sys_department.parent_department_id`
295
1012
  * → members of every descendant via `sys_department_member`
@@ -355,7 +1072,7 @@ var ApprovalService = class {
355
1072
  filter: { team_id: teamId },
356
1073
  fields: ["user_id"],
357
1074
  limit: 1e4,
358
- context: SYSTEM_CTX2
1075
+ context: SYSTEM_CTX
359
1076
  });
360
1077
  } catch {
361
1078
  rows = [];
@@ -370,7 +1087,7 @@ var ApprovalService = class {
370
1087
  filter: organizationId ? { id: departmentId, organization_id: organizationId } : { id: departmentId },
371
1088
  fields: ["id", "active"],
372
1089
  limit: 1,
373
- context: SYSTEM_CTX2
1090
+ context: SYSTEM_CTX
374
1091
  });
375
1092
  const seedRow = Array.isArray(seed) ? seed[0] : null;
376
1093
  if (!seedRow || seedRow.active === false) return [];
@@ -385,7 +1102,7 @@ var ApprovalService = class {
385
1102
  try {
386
1103
  const filter = { parent_department_id: parent, active: { $ne: false } };
387
1104
  if (organizationId) filter.organization_id = organizationId;
388
- kids = await this.engine.find("sys_department", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
1105
+ kids = await this.engine.find("sys_department", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX });
389
1106
  } catch {
390
1107
  kids = [];
391
1108
  }
@@ -403,7 +1120,7 @@ var ApprovalService = class {
403
1120
  filter: { department_id: { $in: Array.from(seen) } },
404
1121
  fields: ["user_id"],
405
1122
  limit: 1e4,
406
- context: SYSTEM_CTX2
1123
+ context: SYSTEM_CTX
407
1124
  });
408
1125
  } catch {
409
1126
  rows = [];
@@ -416,7 +1133,7 @@ var ApprovalService = class {
416
1133
  if (organizationId) filter.organization_id = organizationId;
417
1134
  let rows = [];
418
1135
  try {
419
- rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
1136
+ rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX });
420
1137
  } catch {
421
1138
  rows = [];
422
1139
  }
@@ -428,7 +1145,7 @@ var ApprovalService = class {
428
1145
  filter: { id: userId },
429
1146
  fields: ["id", "manager_id"],
430
1147
  limit: 1,
431
- context: SYSTEM_CTX2
1148
+ context: SYSTEM_CTX
432
1149
  });
433
1150
  const row = Array.isArray(rows) ? rows[0] : null;
434
1151
  return row?.manager_id ? String(row.manager_id) : null;
@@ -436,263 +1153,189 @@ var ApprovalService = class {
436
1153
  return null;
437
1154
  }
438
1155
  }
439
- async notifyRegistryChanged() {
440
- const cb = this.onRegistryChange ?? this.onRegistryChange;
441
- if (!cb) return;
442
- try {
443
- await cb();
444
- } catch (err) {
445
- this.logger?.warn?.("[approvals] onRegistryChange handler failed", { error: err?.message });
446
- }
447
- }
448
- /**
449
- * Look up the HEAD checksum of an approval process from the metadata repo
450
- * (ADR-0009). Returns null when no repo is wired, no metadata exists for
451
- * the name, or the lookup fails — callers MUST treat null as "do not pin"
452
- * and fall back to the projection table.
453
- */
454
- async resolveProcessHash(processName, organizationId) {
455
- if (!this.metadataRepo) return null;
456
- if (!processName) return null;
457
- const orgRef = { org: organizationId || "system", type: "approval", name: processName };
1156
+ /** Mirror a request status onto a business-object field, if configured. */
1157
+ async mirrorStatusField(object, recordId, field, status) {
458
1158
  try {
459
- const head = await this.metadataRepo.get(orgRef);
460
- return head?.hash ?? null;
1159
+ await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
461
1160
  } catch (err) {
462
- this.logger?.debug?.("[approvals] metadataRepo.get failed", { name: processName, error: err?.message });
463
- return null;
1161
+ this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
464
1162
  }
465
1163
  }
1164
+ // ── ADR-0019: Approval-as-flow-node ──────────────────────────
1165
+ //
1166
+ // A flow's Approval node opens a request via `openNodeRequest` (carrying its
1167
+ // own approvers/behavior config and the suspended run id), then suspends. A
1168
+ // later `decide` finalizes it and resumes the flow run down the matching
1169
+ // `approve`/`reject` edge. The record lock is enforced by a beforeUpdate hook
1170
+ // keyed on a *pending* request, so finalizing auto-releases it.
466
1171
  /**
467
- * Resolve the approval process for an in-flight request, honouring
468
- * ADR-0009 execution pinning when a `process_hash` is recorded.
469
- *
470
- * Resolution order:
471
- * 1. If `req.process_hash` AND `metadataRepo` are set, try
472
- * `getByHash` — return a row whose `definition` is the pinned body.
473
- * 2. Otherwise (or on lookup failure) fall back to the current
474
- * projection via `getProcess(req.process_name)`.
1172
+ * Open a pending approval request on behalf of a flow's Approval node. The
1173
+ * node config (approvers / behavior / status field) is snapshotted on the row
1174
+ * so a decision can be made without any process to resolve against.
475
1175
  */
476
- async loadProcessForRequest(req, context) {
477
- const hash = req.process_hash;
478
- if (hash && this.metadataRepo) {
479
- const orgId = req.organization_id ?? null;
480
- const orgRef = { org: orgId || "system", type: "approval", name: req.process_name };
481
- try {
482
- const pinned = await this.metadataRepo.getByHash(orgRef, hash);
483
- if (pinned?.body) {
484
- const current = await this.getProcess(req.process_name, context);
485
- const body = pinned.body;
486
- return {
487
- id: current?.id ?? `pinned_${hash.slice(7, 19)}`,
488
- name: req.process_name,
489
- label: body.label ?? current?.label ?? req.process_name,
490
- object_name: req.object_name,
491
- description: body.description ?? current?.description,
492
- active: current?.active ?? true,
493
- definition: body,
494
- created_at: current?.created_at,
495
- updated_at: current?.updated_at
496
- };
497
- }
498
- this.logger?.warn?.("[approvals] pinned process body not found; falling back to current", {
499
- request: req.id,
500
- process: req.process_name,
501
- hash
502
- });
503
- } catch (err) {
504
- this.logger?.warn?.("[approvals] getByHash failed; falling back to current", {
505
- request: req.id,
506
- error: err?.message
507
- });
508
- }
509
- }
510
- return this.getProcess(req.process_name, context);
511
- }
512
- /** Mirror request status onto `process.approvalStatusField` if configured. */
513
- async syncStatusField(process, request) {
514
- const field = process.definition?.approvalStatusField;
515
- if (!field) return;
516
- try {
517
- await this.engine.update(
518
- process.object_name,
519
- { id: request.record_id, [field]: request.status },
520
- { context: SYSTEM_CTX2 }
521
- );
522
- } catch (err) {
523
- this.logger?.warn?.(`[approvals] syncStatusField failed: ${err?.message ?? err}`);
524
- }
525
- }
526
- /** Convenience wrapper that funnels every action invocation through the executor. */
527
- async runActions(actions, trigger, process, request, step, actorId, comment) {
528
- if (!actions || actions.length === 0) return;
529
- await executeActions(actions, {
530
- trigger,
531
- process: { ...process, object: process.object_name },
532
- request,
533
- step,
534
- actorId: actorId ?? null,
535
- comment: comment ?? null
536
- }, {
537
- engine: this.engine,
538
- logger: this.logger,
539
- fetch: this.fetchImpl,
540
- webhookTimeoutMs: this.webhookTimeoutMs
541
- });
542
- }
543
- // ── Process definitions ──────────────────────────────────────
544
- async defineProcess(input, _context) {
545
- if (!input.name) throw new Error("VALIDATION_FAILED: name is required");
546
- if (!input.label) throw new Error("VALIDATION_FAILED: label is required");
547
- if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
548
- if (!input.definition) throw new Error("VALIDATION_FAILED: definition is required");
549
- const parsed = ApprovalProcessSchema.safeParse(input.definition);
550
- if (!parsed.success) {
551
- const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
552
- throw new Error(`VALIDATION_FAILED: ${msg}`);
553
- }
554
- const now = this.clock.now().toISOString();
555
- const payload = {
556
- name: input.name,
557
- label: input.label,
558
- object_name: input.object,
559
- description: input.description ?? null,
560
- active: input.active !== false,
561
- definition_json: JSON.stringify(parsed.data),
562
- updated_at: now
563
- };
564
- const existing = await this.engine.find("sys_approval_process", {
565
- where: { name: input.name },
566
- limit: 1,
567
- context: SYSTEM_CTX2
568
- });
569
- if (Array.isArray(existing) && existing[0]) {
570
- const id2 = existing[0].id;
571
- await this.engine.update("sys_approval_process", { id: id2, ...payload }, { context: SYSTEM_CTX2 });
572
- const row2 = rowFromProcess({ ...existing[0], ...payload, id: id2 });
573
- await this.notifyRegistryChanged();
574
- return row2;
575
- }
576
- const id = input.id ?? uid("apv");
577
- const row = { id, ...payload, created_at: now };
578
- await this.engine.insert("sys_approval_process", row, { context: SYSTEM_CTX2 });
579
- const out = rowFromProcess(row);
580
- await this.notifyRegistryChanged();
581
- return out;
582
- }
583
- async listProcesses(filter, _context) {
584
- const f = {};
585
- if (filter?.object) f.object_name = filter.object;
586
- if (filter?.activeOnly) f.active = true;
587
- const rows = await this.engine.find("sys_approval_process", {
588
- where: f,
589
- limit: 500,
590
- orderBy: [{ field: "updated_at", direction: "desc" }],
591
- context: SYSTEM_CTX2
592
- });
593
- return Array.isArray(rows) ? rows.map(rowFromProcess) : [];
594
- }
595
- async getProcess(idOrName, _context) {
596
- if (!idOrName) return null;
597
- let rows = await this.engine.find("sys_approval_process", {
598
- where: { id: idOrName },
599
- limit: 1,
600
- context: SYSTEM_CTX2
601
- });
602
- if (!Array.isArray(rows) || !rows[0]) {
603
- rows = await this.engine.find("sys_approval_process", {
604
- where: { name: idOrName },
605
- limit: 1,
606
- context: SYSTEM_CTX2
607
- });
608
- }
609
- return Array.isArray(rows) && rows[0] ? rowFromProcess(rows[0]) : null;
610
- }
611
- async deleteProcess(idOrName, context) {
612
- if (!idOrName) throw new Error("VALIDATION_FAILED: idOrName is required");
613
- const proc = await this.getProcess(idOrName, context);
614
- if (!proc) return;
615
- await this.engine.delete("sys_approval_process", { where: { id: proc.id }, context: SYSTEM_CTX2 });
616
- await this.notifyRegistryChanged();
617
- }
618
- // ── Requests ─────────────────────────────────────────────────
619
- async submit(input, context) {
1176
+ async openNodeRequest(input, context) {
620
1177
  if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
621
1178
  if (!input.recordId) throw new Error("VALIDATION_FAILED: recordId is required");
622
- let process = null;
623
- if (input.processName) {
624
- process = await this.getProcess(input.processName, context);
625
- if (process && !process.active) {
626
- throw new Error(`NO_ACTIVE_PROCESS: process '${input.processName}' is not active`);
627
- }
628
- } else {
629
- const list = await this.listProcesses({ object: input.object, activeOnly: true }, context);
630
- process = list[0] ?? null;
631
- }
632
- if (!process) {
633
- throw new Error(`NO_ACTIVE_PROCESS: no active approval process for object '${input.object}'`);
634
- }
1179
+ if (!input.runId) throw new Error("VALIDATION_FAILED: runId is required");
635
1180
  const existing = await this.engine.find("sys_approval_request", {
636
1181
  where: { object_name: input.object, record_id: input.recordId, status: "pending" },
637
1182
  limit: 1,
638
- context: SYSTEM_CTX2
1183
+ context: SYSTEM_CTX
639
1184
  });
640
1185
  if (Array.isArray(existing) && existing[0]) {
641
1186
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
642
1187
  }
643
- const steps = process.definition?.steps ?? [];
644
- if (steps.length === 0) {
645
- throw new Error("VALIDATION_FAILED: process definition has no steps");
646
- }
647
- const step0 = steps[0];
648
- const ctxOrg = context?.organizationId ?? context?.tenantId ?? null;
649
- const approvers = await this.expandApprovers(step0, input.payload, ctxOrg);
1188
+ const ctxOrg = context?.organizationId ?? context?.tenantId ?? input.organizationId ?? null;
1189
+ const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
650
1190
  const now = this.clock.now().toISOString();
651
1191
  const id = uid("areq");
652
- const processHash = await this.resolveProcessHash(process.name, ctxOrg);
1192
+ const processName = `flow:${input.flowName ?? input.nodeId}`;
653
1193
  const row = {
654
1194
  id,
655
- process_name: process.name,
656
- process_hash: processHash,
1195
+ process_name: processName,
657
1196
  object_name: input.object,
658
1197
  record_id: input.recordId,
659
1198
  submitter_id: input.submitterId ?? context.userId ?? null,
660
- submitter_comment: input.comment ?? null,
661
1199
  status: "pending",
662
- current_step: step0.name,
1200
+ current_step: input.nodeId,
663
1201
  current_step_index: 0,
664
1202
  pending_approvers: approvers.join(","),
665
- payload_json: input.payload != null ? JSON.stringify(input.payload) : null,
1203
+ payload_json: input.record != null ? JSON.stringify(input.record) : null,
1204
+ flow_run_id: input.runId,
1205
+ flow_node_id: input.nodeId,
1206
+ node_config_json: JSON.stringify(input.config),
666
1207
  organization_id: ctxOrg,
667
1208
  created_at: now,
668
1209
  updated_at: now
669
1210
  };
670
- await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
1211
+ await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
671
1212
  await this.engine.insert("sys_approval_action", {
672
1213
  id: uid("aact"),
673
1214
  request_id: id,
674
1215
  organization_id: ctxOrg,
675
- step_name: step0.name,
1216
+ step_name: input.nodeId,
676
1217
  step_index: 0,
677
1218
  action: "submit",
678
1219
  actor_id: input.submitterId ?? context.userId ?? null,
1220
+ comment: null,
1221
+ created_at: now
1222
+ }, { context: SYSTEM_CTX });
1223
+ if (input.config.approvalStatusField) {
1224
+ await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
1225
+ }
1226
+ return rowFromRequest(row);
1227
+ }
1228
+ /**
1229
+ * Record a decision on a node-driven request. Honours the node's `unanimous`
1230
+ * behavior (holds until every approver has approved). When the request
1231
+ * finalizes, returns the suspended run id + node id so the caller (or
1232
+ * {@link ApprovalService.decide}) can resume the flow down the matching
1233
+ * branch.
1234
+ */
1235
+ async decideNode(requestId, input, context) {
1236
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
1237
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1238
+ if (input.decision !== "approve" && input.decision !== "reject") {
1239
+ throw new Error("VALIDATION_FAILED: decision must be approve|reject");
1240
+ }
1241
+ const rawRows = await this.engine.find("sys_approval_request", {
1242
+ where: { id: requestId },
1243
+ limit: 1,
1244
+ context: SYSTEM_CTX
1245
+ });
1246
+ const raw = Array.isArray(rawRows) ? rawRows[0] : null;
1247
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1248
+ if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1249
+ const pendingApprovers = csvSplit(raw.pending_approvers);
1250
+ if (!context.isSystem && !pendingApprovers.includes(input.actorId)) {
1251
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1252
+ }
1253
+ const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
1254
+ const org = raw.organization_id ?? null;
1255
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
1256
+ const runId = raw.flow_run_id ?? null;
1257
+ const now = this.clock.now().toISOString();
1258
+ await this.engine.insert("sys_approval_action", {
1259
+ id: uid("aact"),
1260
+ request_id: requestId,
1261
+ organization_id: org,
1262
+ step_name: nodeId,
1263
+ step_index: 0,
1264
+ action: input.decision,
1265
+ actor_id: input.actorId,
679
1266
  comment: input.comment ?? null,
680
1267
  created_at: now
681
- }, { context: SYSTEM_CTX2 });
682
- const requestRow = rowFromRequest(row);
683
- await this.syncStatusField(process, requestRow);
684
- const definition = process.definition ?? {};
685
- await this.runActions(
686
- definition.onSubmit,
687
- "submit",
688
- process,
689
- requestRow,
690
- step0,
691
- input.submitterId ?? context.userId ?? null,
692
- input.comment ?? null
693
- );
694
- return requestRow;
1268
+ }, { context: SYSTEM_CTX });
1269
+ if (input.decision === "approve" && config.behavior === "unanimous") {
1270
+ const original = await this.expandApprovers(
1271
+ { approvers: config.approvers },
1272
+ parseJson(raw.payload_json, void 0),
1273
+ org
1274
+ );
1275
+ const acts = await this.engine.find("sys_approval_action", {
1276
+ where: { request_id: requestId, step_index: 0, action: "approve" },
1277
+ limit: 500,
1278
+ context: SYSTEM_CTX
1279
+ });
1280
+ const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
1281
+ const stillPending = original.filter((a) => !approved.has(a));
1282
+ if (stillPending.length > 0) {
1283
+ await this.engine.update("sys_approval_request", {
1284
+ id: requestId,
1285
+ pending_approvers: stillPending.join(","),
1286
+ updated_at: now
1287
+ }, { context: SYSTEM_CTX });
1288
+ const fresh2 = await this.getRequest(requestId, context);
1289
+ return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
1290
+ }
1291
+ }
1292
+ const finalStatus = input.decision === "approve" ? "approved" : "rejected";
1293
+ await this.engine.update("sys_approval_request", {
1294
+ id: requestId,
1295
+ status: finalStatus,
1296
+ pending_approvers: null,
1297
+ completed_at: now,
1298
+ updated_at: now
1299
+ }, { context: SYSTEM_CTX });
1300
+ if (config.approvalStatusField) {
1301
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
1302
+ }
1303
+ const fresh = await this.getRequest(requestId, context);
1304
+ return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
695
1305
  }
1306
+ /**
1307
+ * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
1308
+ * request via {@link ApprovalService.decideNode} and, when it finalizes,
1309
+ * resumes the owning flow run down the matching `approve` / `reject` edge.
1310
+ */
1311
+ async decide(requestId, input, context) {
1312
+ const result = await this.decideNode(requestId, input, context);
1313
+ let resumed = false;
1314
+ if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
1315
+ const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
1316
+ try {
1317
+ await this.automation.resume(result.runId, {
1318
+ branchLabel,
1319
+ output: { decision: result.decision, requestId }
1320
+ });
1321
+ resumed = true;
1322
+ } catch (err) {
1323
+ this.logger?.warn?.("[approvals] resume after decision failed", {
1324
+ request: requestId,
1325
+ run: result.runId,
1326
+ error: err?.message ?? String(err)
1327
+ });
1328
+ }
1329
+ }
1330
+ return {
1331
+ request: result.request,
1332
+ finalized: result.finalized,
1333
+ decision: result.decision,
1334
+ runId: result.runId,
1335
+ resumed
1336
+ };
1337
+ }
1338
+ // ── Read API ─────────────────────────────────────────────────
696
1339
  async listRequests(filter, context) {
697
1340
  const f = {};
698
1341
  if (filter?.object) f.object_name = filter.object;
@@ -707,7 +1350,7 @@ var ApprovalService = class {
707
1350
  where: f,
708
1351
  limit: 500,
709
1352
  orderBy: [{ field: "updated_at", direction: "desc" }],
710
- context: SYSTEM_CTX2
1353
+ context: SYSTEM_CTX
711
1354
  });
712
1355
  let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
713
1356
  if (statusFilter) list = list.filter((r) => statusFilter.includes(r.status));
@@ -725,169 +1368,10 @@ var ApprovalService = class {
725
1368
  const rows = await this.engine.find("sys_approval_request", {
726
1369
  where,
727
1370
  limit: 1,
728
- context: SYSTEM_CTX2
1371
+ context: SYSTEM_CTX
729
1372
  });
730
1373
  return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
731
1374
  }
732
- async approve(requestId, input, context) {
733
- const req = await this.getRequest(requestId, context);
734
- if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
735
- if (req.status !== "pending") throw new Error(`INVALID_STATE: request is ${req.status}`);
736
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
737
- if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
738
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
739
- }
740
- const process = await this.loadProcessForRequest(req, context);
741
- if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
742
- const steps = process.definition?.steps ?? [];
743
- const stepIndex = req.current_step_index ?? 0;
744
- const step = steps[stepIndex];
745
- if (!step) throw new Error(`INVALID_STATE: step index ${stepIndex} out of range`);
746
- const now = this.clock.now().toISOString();
747
- await this.engine.insert("sys_approval_action", {
748
- id: uid("aact"),
749
- request_id: req.id,
750
- organization_id: req.organization_id ?? null,
751
- step_name: step.name,
752
- step_index: stepIndex,
753
- action: "approve",
754
- actor_id: input.actorId,
755
- comment: input.comment ?? null,
756
- created_at: now
757
- }, { context: SYSTEM_CTX2 });
758
- if (step.behavior === "unanimous") {
759
- const original = await this.expandApprovers(step, req.payload, req.organization_id ?? null);
760
- const acts = await this.engine.find("sys_approval_action", {
761
- where: { request_id: req.id, step_index: stepIndex, action: "approve" },
762
- limit: 500,
763
- context: SYSTEM_CTX2
764
- });
765
- const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
766
- const stillPending = original.filter((a) => !approved.has(a));
767
- if (stillPending.length > 0) {
768
- await this.engine.update("sys_approval_request", {
769
- id: req.id,
770
- pending_approvers: stillPending.join(","),
771
- updated_at: now
772
- }, { context: SYSTEM_CTX2 });
773
- const fresh2 = await this.getRequest(req.id, context);
774
- return { request: fresh2, finalized: false };
775
- }
776
- }
777
- if (stepIndex + 1 >= steps.length) {
778
- await this.engine.update("sys_approval_request", {
779
- id: req.id,
780
- status: "approved",
781
- pending_approvers: null,
782
- completed_at: now,
783
- updated_at: now
784
- }, { context: SYSTEM_CTX2 });
785
- const fresh2 = await this.getRequest(req.id, context);
786
- await this.runActions(step?.onApprove, "step_approve", process, fresh2, step, input.actorId, input.comment);
787
- await this.syncStatusField(process, fresh2);
788
- await this.runActions(process.definition?.onFinalApprove, "final_approve", process, fresh2, step, input.actorId, input.comment);
789
- return { request: fresh2, finalized: true };
790
- }
791
- const nextStep = steps[stepIndex + 1];
792
- const nextApprovers = await this.expandApprovers(nextStep, req.payload, req.organization_id ?? null);
793
- await this.engine.update("sys_approval_request", {
794
- id: req.id,
795
- current_step: nextStep.name,
796
- current_step_index: stepIndex + 1,
797
- pending_approvers: nextApprovers.join(","),
798
- updated_at: now
799
- }, { context: SYSTEM_CTX2 });
800
- const fresh = await this.getRequest(req.id, context);
801
- await this.runActions(step?.onApprove, "step_approve", process, fresh, step, input.actorId, input.comment);
802
- return { request: fresh, finalized: false };
803
- }
804
- async reject(requestId, input, context) {
805
- const req = await this.getRequest(requestId, context);
806
- if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
807
- if (req.status !== "pending") throw new Error(`INVALID_STATE: request is ${req.status}`);
808
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
809
- if (!context.isSystem && !(req.pending_approvers ?? []).includes(input.actorId)) {
810
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
811
- }
812
- const process = await this.loadProcessForRequest(req, context);
813
- if (!process) throw new Error(`PROCESS_NOT_FOUND: ${req.process_name}`);
814
- const steps = process.definition?.steps ?? [];
815
- const stepIndex = req.current_step_index ?? 0;
816
- const step = steps[stepIndex];
817
- const now = this.clock.now().toISOString();
818
- await this.engine.insert("sys_approval_action", {
819
- id: uid("aact"),
820
- request_id: req.id,
821
- organization_id: req.organization_id ?? null,
822
- step_name: step?.name,
823
- step_index: stepIndex,
824
- action: "reject",
825
- actor_id: input.actorId,
826
- comment: input.comment ?? null,
827
- created_at: now
828
- }, { context: SYSTEM_CTX2 });
829
- if (step?.rejectionBehavior === "back_to_previous" && stepIndex > 0) {
830
- const prev = steps[stepIndex - 1];
831
- const prevApprovers = await this.expandApprovers(prev, req.payload, req.organization_id ?? null);
832
- await this.engine.update("sys_approval_request", {
833
- id: req.id,
834
- current_step: prev.name,
835
- current_step_index: stepIndex - 1,
836
- pending_approvers: prevApprovers.join(","),
837
- updated_at: now
838
- }, { context: SYSTEM_CTX2 });
839
- const fresh2 = await this.getRequest(req.id, context);
840
- await this.runActions(step?.onReject, "step_reject", process, fresh2, step, input.actorId, input.comment);
841
- return { request: fresh2, finalized: false };
842
- }
843
- await this.engine.update("sys_approval_request", {
844
- id: req.id,
845
- status: "rejected",
846
- pending_approvers: null,
847
- completed_at: now,
848
- updated_at: now
849
- }, { context: SYSTEM_CTX2 });
850
- const fresh = await this.getRequest(req.id, context);
851
- await this.runActions(step?.onReject, "step_reject", process, fresh, step, input.actorId, input.comment);
852
- await this.syncStatusField(process, fresh);
853
- await this.runActions(process.definition?.onFinalReject, "final_reject", process, fresh, step, input.actorId, input.comment);
854
- return { request: fresh, finalized: true };
855
- }
856
- async recall(requestId, input, context) {
857
- const req = await this.getRequest(requestId, context);
858
- if (!req) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
859
- if (req.status !== "pending") throw new Error(`INVALID_STATE: request is ${req.status}`);
860
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
861
- if (!context.isSystem && req.submitter_id && req.submitter_id !== input.actorId) {
862
- throw new Error(`FORBIDDEN: only the submitter can recall this request`);
863
- }
864
- const now = this.clock.now().toISOString();
865
- await this.engine.insert("sys_approval_action", {
866
- id: uid("aact"),
867
- request_id: req.id,
868
- organization_id: req.organization_id ?? null,
869
- step_name: req.current_step,
870
- step_index: req.current_step_index,
871
- action: "recall",
872
- actor_id: input.actorId,
873
- comment: input.comment ?? null,
874
- created_at: now
875
- }, { context: SYSTEM_CTX2 });
876
- await this.engine.update("sys_approval_request", {
877
- id: req.id,
878
- status: "recalled",
879
- pending_approvers: null,
880
- completed_at: now,
881
- updated_at: now
882
- }, { context: SYSTEM_CTX2 });
883
- const fresh = await this.getRequest(req.id, context);
884
- const process = await this.loadProcessForRequest(req, context);
885
- if (process) {
886
- await this.syncStatusField(process, fresh);
887
- await this.runActions(process.definition?.onRecall, "recall", process, fresh, void 0, input.actorId, input.comment);
888
- }
889
- return { request: fresh, finalized: true };
890
- }
891
1375
  async listActions(requestId, context) {
892
1376
  if (!requestId) return [];
893
1377
  const req = await this.getRequest(requestId, context);
@@ -896,164 +1380,136 @@ var ApprovalService = class {
896
1380
  where: { request_id: requestId },
897
1381
  limit: 500,
898
1382
  orderBy: [{ field: "created_at", direction: "asc" }],
899
- context: SYSTEM_CTX2
1383
+ context: SYSTEM_CTX
900
1384
  });
901
1385
  return Array.isArray(rows) ? rows.map(rowFromAction) : [];
902
1386
  }
903
1387
  };
904
1388
 
905
- // src/approvals-plugin.ts
906
- import {
907
- SysApprovalProcess,
908
- SysApprovalRequest,
909
- SysApprovalAction
910
- } from "@objectstack/platform-objects/audit";
911
-
912
1389
  // src/lifecycle-hooks.ts
913
- import { ExpressionEngine } from "@objectstack/formula";
914
- var APPROVALS_HOOK_PACKAGE = "plugin-approvals:auto";
915
- var SYSTEM_CTX3 = { isSystem: true, roles: [], permissions: [] };
916
- function evaluateCriteria(criteria, record, logger) {
917
- if (criteria == null || criteria === "") return true;
918
- let expr;
919
- if (typeof criteria === "string") {
920
- expr = { dialect: "cel", source: criteria };
921
- } else if (typeof criteria === "object" && criteria.dialect) {
922
- expr = criteria;
923
- } else {
924
- return true;
925
- }
926
- if (!expr.source || !expr.source.trim()) return true;
927
- const r = ExpressionEngine.evaluate(expr, { record });
928
- if (!r.ok) {
929
- logger?.warn?.("[approvals] entryCriteria evaluation failed; skipping auto-submit", {
930
- source: expr.source,
931
- error: r.error.message
932
- });
933
- return false;
1390
+ var APPROVALS_HOOK_PACKAGE = "plugin-approvals:lock";
1391
+ function parseJson2(raw, fallback) {
1392
+ if (raw == null || raw === "") return fallback;
1393
+ if (typeof raw === "string") {
1394
+ try {
1395
+ return JSON.parse(raw);
1396
+ } catch {
1397
+ return fallback;
1398
+ }
934
1399
  }
935
- return Boolean(r.value);
1400
+ return raw;
936
1401
  }
937
- async function hasPendingRequest(engine, objectName, recordId) {
1402
+ async function pendingRequestFor(engine, objectName, recordId) {
938
1403
  try {
939
1404
  const rows = await engine.find("sys_approval_request", {
940
1405
  where: { object_name: objectName, record_id: String(recordId), status: "pending" },
941
1406
  limit: 1
942
1407
  });
943
- return Array.isArray(rows) && rows.length > 0;
1408
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
944
1409
  } catch {
945
- return false;
1410
+ return null;
946
1411
  }
947
1412
  }
948
- function bindProcessHooks(engine, service, processes, logger) {
949
- const byObject = /* @__PURE__ */ new Map();
950
- for (const p of processes) {
951
- if (!p.active && !p.is_active) continue;
952
- if (!p.object_name) continue;
953
- const list = byObject.get(p.object_name) ?? [];
954
- list.push(p);
955
- byObject.set(p.object_name, list);
956
- }
957
- for (const [objectName, procs] of byObject.entries()) {
958
- engine.registerHook("afterInsert", async (ctx) => {
959
- try {
960
- const record = ctx?.result ?? ctx?.input?.data ?? {};
961
- const id = String(record?.id ?? "");
962
- if (!id) return;
963
- for (const proc of procs) {
964
- await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger);
965
- }
966
- } catch (err) {
967
- logger?.warn?.("[approvals] afterInsert auto-trigger failed", { error: err?.message });
968
- }
969
- }, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 });
970
- engine.registerHook("afterUpdate", async (ctx) => {
971
- if (ctx?.session?.isSystem) return;
972
- try {
973
- const result = ctx?.result ?? {};
974
- const id = String(ctx?.input?.id ?? result?.id ?? "");
975
- if (!id) return;
976
- const record = {
977
- ...ctx?.previous ?? {},
978
- ...result?.id ? result : {},
979
- ...ctx?.input?.data ?? {},
980
- id
981
- };
982
- for (const proc of procs) {
983
- await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger);
984
- }
985
- } catch (err) {
986
- logger?.warn?.("[approvals] afterUpdate auto-trigger failed", { error: err?.message });
987
- }
988
- }, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 });
989
- const lockProcs = procs.filter((p) => p.definition?.lockRecord !== false);
990
- if (lockProcs.length === 0) continue;
991
- engine.registerHook("beforeUpdate", async (ctx) => {
992
- const id = String(ctx?.input?.id ?? "");
993
- if (!id) return;
994
- const data = ctx?.input?.data ?? {};
995
- const changedFields = Object.keys(data).filter((k) => k !== "id" && k !== "updated_at");
996
- if (changedFields.length === 0) return;
997
- if (ctx?.session?.isSystem) return;
998
- const mirrorFields = /* @__PURE__ */ new Set();
999
- for (const p of lockProcs) {
1000
- const f = p.definition?.approvalStatusField;
1001
- if (typeof f === "string" && f) mirrorFields.add(f);
1002
- }
1003
- const onlyMirror = changedFields.every((f) => mirrorFields.has(f));
1004
- if (onlyMirror) return;
1005
- const roles = ctx?.session?.roles ?? [];
1006
- if (Array.isArray(roles) && roles.includes("admin")) return;
1007
- const pending = await hasPendingRequest(engine, objectName, id);
1008
- if (!pending) return;
1009
- const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
1010
- err.code = "RECORD_LOCKED";
1011
- err.statusCode = 409;
1012
- throw err;
1013
- }, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
1014
- }
1015
- logger?.info?.("[approvals] lifecycle hooks bound", {
1016
- objects: Array.from(byObject.keys()),
1017
- processCount: processes.length
1018
- });
1413
+ function bindApprovalLockHook(engine, logger) {
1414
+ engine.registerHook("beforeUpdate", async (ctx) => {
1415
+ const id = String(ctx?.input?.id ?? "");
1416
+ if (!id) return;
1417
+ const object = ctx?.object ?? ctx?.objectName;
1418
+ if (!object || String(object).startsWith("sys_approval")) return;
1419
+ const data = ctx?.input?.data ?? {};
1420
+ const changedFields = Object.keys(data).filter((k) => k !== "id" && k !== "updated_at");
1421
+ if (changedFields.length === 0) return;
1422
+ if (ctx?.session?.isSystem) return;
1423
+ const roles = ctx?.session?.roles ?? [];
1424
+ if (Array.isArray(roles) && roles.includes("admin")) return;
1425
+ const pending = await pendingRequestFor(engine, object, id);
1426
+ if (!pending) return;
1427
+ const config = parseJson2(pending.node_config_json, {});
1428
+ if (config?.lockRecord === false) return;
1429
+ const mirror = config?.approvalStatusField;
1430
+ if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) return;
1431
+ const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
1432
+ err.code = "RECORD_LOCKED";
1433
+ err.statusCode = 409;
1434
+ throw err;
1435
+ }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
1436
+ logger?.info?.("[approvals] record-lock hook bound");
1019
1437
  }
1020
1438
  function unbindAllHooks(engine) {
1021
1439
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
1022
1440
  }
1023
- async function tryAutoSubmit(engine, service, process, objectName, recordId, record, ctx, logger) {
1024
- try {
1025
- const criteria = process.definition?.entryCriteria;
1026
- const passes = evaluateCriteria(criteria, record, logger);
1027
- if (!passes) return;
1028
- if (await hasPendingRequest(engine, objectName, recordId)) return;
1029
- const statusField = process.definition?.approvalStatusField;
1030
- if (statusField) {
1031
- const current = record?.[statusField];
1032
- if (current === "approved" || current === "rejected" || current === "recalled") return;
1441
+
1442
+ // src/approval-node.ts
1443
+ import {
1444
+ defineActionDescriptor,
1445
+ ApprovalNodeConfigSchema,
1446
+ getApprovalNodeConfigJsonSchema,
1447
+ APPROVAL_NODE_TYPE
1448
+ } from "@objectstack/spec/automation";
1449
+ var SYSTEM_CTX2 = { isSystem: true, roles: [], permissions: [] };
1450
+ function registerApprovalNode(automation, service, logger) {
1451
+ automation.registerNodeExecutor({
1452
+ type: APPROVAL_NODE_TYPE,
1453
+ descriptor: defineActionDescriptor({
1454
+ type: APPROVAL_NODE_TYPE,
1455
+ version: "1.0.0",
1456
+ name: "Approval",
1457
+ description: "Route a record for human approval; suspends the flow until a decision, then continues down the approve / reject branch.",
1458
+ icon: "check-circle",
1459
+ category: "human",
1460
+ paradigms: ["flow"],
1461
+ source: "plugin",
1462
+ // Human decision: the run suspends here awaiting an external reply.
1463
+ supportsPause: true,
1464
+ isAsync: true,
1465
+ // Publish the node's config contract (ADR-0018 §configSchema) so the
1466
+ // Studio flow designer renders the Approval property form from the engine
1467
+ // rather than a hardcoded client form — the engine owns the shape.
1468
+ configSchema: getApprovalNodeConfigJsonSchema()
1469
+ }),
1470
+ async execute(node, variables, context) {
1471
+ const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {});
1472
+ if (!parsed.success) {
1473
+ const msg = parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
1474
+ return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` };
1475
+ }
1476
+ const config = parsed.data;
1477
+ const runId = variables.get("$runId");
1478
+ const record = variables.get("$record") ?? context?.record ?? {};
1479
+ const object = context?.object ?? record?.object_name;
1480
+ const recordId = record?.id;
1481
+ if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
1482
+ if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
1483
+ if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
1484
+ try {
1485
+ const request = await service.openNodeRequest({
1486
+ object,
1487
+ recordId: String(recordId),
1488
+ runId: String(runId),
1489
+ nodeId: node.id,
1490
+ config,
1491
+ flowName: context?.flowName,
1492
+ submitterId: context?.userId ?? null,
1493
+ record,
1494
+ organizationId: context?.organizationId ?? context?.tenantId ?? null
1495
+ }, {
1496
+ ...SYSTEM_CTX2,
1497
+ userId: context?.userId,
1498
+ organizationId: context?.organizationId,
1499
+ tenantId: context?.tenantId
1500
+ });
1501
+ logger?.info?.("[approvals] approval node suspended run", {
1502
+ node: node.id,
1503
+ request: request.id,
1504
+ run: String(runId)
1505
+ });
1506
+ return { success: true, suspend: true, correlation: request.id };
1507
+ } catch (err) {
1508
+ return { success: false, error: `Approval node '${node.id}': ${err?.message ?? String(err)}` };
1509
+ }
1033
1510
  }
1034
- const submitterId = ctx?.session?.userId ?? null;
1035
- const submitterOrg = ctx?.session?.tenantId ?? ctx?.session?.organizationId ?? null;
1036
- await service.submit({
1037
- object: objectName,
1038
- recordId,
1039
- processName: process.name,
1040
- payload: record,
1041
- submitterId
1042
- }, { ...SYSTEM_CTX3, userId: submitterId ?? void 0, organizationId: submitterOrg ?? void 0, tenantId: submitterOrg ?? void 0 });
1043
- logger?.info?.("[approvals] auto-submitted approval", {
1044
- process: process.name,
1045
- object: objectName,
1046
- record: recordId
1047
- });
1048
- } catch (err) {
1049
- if (err?.code === "DUPLICATE_REQUEST") return;
1050
- logger?.warn?.("[approvals] auto-submit failed", {
1051
- process: process.name,
1052
- object: objectName,
1053
- record: recordId,
1054
- error: err?.message ?? String(err)
1055
- });
1056
- }
1511
+ });
1512
+ logger?.info?.("[approvals] approval node executor registered");
1057
1513
  }
1058
1514
 
1059
1515
  // src/approvals-plugin.ts
@@ -1074,8 +1530,36 @@ var ApprovalsServicePlugin = class {
1074
1530
  scope: "system",
1075
1531
  defaultDatasource: "cloud",
1076
1532
  namespace: "sys",
1077
- objects: [SysApprovalProcess, SysApprovalRequest, SysApprovalAction]
1533
+ objects: [SysApprovalRequest, SysApprovalAction],
1534
+ // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
1535
+ // `group_approvals` slot. This plugin owns these objects (K2.b), so it
1536
+ // ships their menu too; when the plugin isn't installed the slot is empty.
1537
+ navigationContributions: [
1538
+ {
1539
+ app: "setup",
1540
+ group: "group_approvals",
1541
+ priority: 100,
1542
+ items: [
1543
+ { id: "nav_approval_requests", type: "object", label: "Requests", objectName: "sys_approval_request", icon: "inbox", requiresObject: "sys_approval_request" },
1544
+ { id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" }
1545
+ ]
1546
+ }
1547
+ ]
1078
1548
  });
1549
+ if (typeof ctx.hook === "function") {
1550
+ ctx.hook("kernel:ready", async () => {
1551
+ try {
1552
+ const i18n = ctx.getService("i18n");
1553
+ if (i18n && typeof i18n.loadTranslations === "function") {
1554
+ const { ApprovalsTranslations: ApprovalsTranslations2 } = await Promise.resolve().then(() => (init_translations(), translations_exports));
1555
+ for (const [locale, data] of Object.entries(ApprovalsTranslations2)) {
1556
+ i18n.loadTranslations(locale, data);
1557
+ }
1558
+ }
1559
+ } catch {
1560
+ }
1561
+ });
1562
+ }
1079
1563
  ctx.logger.info("ApprovalsServicePlugin: schemas registered");
1080
1564
  }
1081
1565
  async start(ctx) {
@@ -1094,47 +1578,28 @@ var ApprovalsServicePlugin = class {
1094
1578
  return;
1095
1579
  }
1096
1580
  this.engine = engine;
1097
- this.logger = ctx.logger;
1098
- let metadataRepo;
1099
- try {
1100
- const meta = ctx.getService("metadata");
1101
- metadataRepo = meta?.getRepository?.();
1102
- } catch {
1103
- }
1104
1581
  this.service = new ApprovalService({
1105
1582
  engine,
1106
- logger: ctx.logger,
1107
- metadataRepo
1583
+ logger: ctx.logger
1108
1584
  });
1109
- if (metadataRepo) {
1110
- ctx.logger.info("ApprovalsServicePlugin: execution pinning enabled (ADR-0009)");
1111
- }
1112
1585
  if (!this.options.disableAutoHooks) {
1113
- this.service.setRegistryChangeHandler(() => this.rebindHooks());
1114
- const hookOn = ctx.hook ?? ctx.on;
1115
- if (typeof hookOn === "function") {
1116
- try {
1117
- hookOn.call(ctx, "kernel:ready", async () => {
1118
- await this.rebindHooks();
1119
- });
1120
- } catch {
1121
- await this.rebindHooks();
1122
- }
1123
- } else {
1124
- await this.rebindHooks();
1586
+ try {
1587
+ unbindAllHooks(engine);
1588
+ bindApprovalLockHook(engine, ctx.logger);
1589
+ } catch (err) {
1590
+ ctx.logger.warn?.("[approvals] failed to bind record-lock hook", { error: err?.message });
1125
1591
  }
1126
1592
  }
1127
1593
  ctx.registerService("approvals", this.service);
1128
1594
  ctx.logger.info("ApprovalsServicePlugin: service registered");
1129
- }
1130
- async rebindHooks() {
1131
- if (!this.engine || !this.service) return;
1132
1595
  try {
1133
- unbindAllHooks(this.engine);
1134
- const processes = await this.service.listProcesses({ activeOnly: true }, { isSystem: true, roles: [], permissions: [] });
1135
- bindProcessHooks(this.engine, this.service, processes, this.logger);
1136
- } catch (err) {
1137
- this.logger?.warn?.("[approvals] rebindHooks failed", { error: err?.message });
1596
+ const automation = ctx.getService("automation");
1597
+ if (automation && typeof automation.registerNodeExecutor === "function") {
1598
+ this.service.attachAutomation(automation);
1599
+ registerApprovalNode(automation, this.service, ctx.logger);
1600
+ }
1601
+ } catch {
1602
+ ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
1138
1603
  }
1139
1604
  }
1140
1605
  async stop(_ctx) {
@@ -1149,8 +1614,8 @@ var ApprovalsServicePlugin = class {
1149
1614
  export {
1150
1615
  ApprovalService,
1151
1616
  ApprovalsServicePlugin,
1152
- SysApprovalAction2 as SysApprovalAction,
1153
- SysApprovalProcess2 as SysApprovalProcess,
1154
- SysApprovalRequest2 as SysApprovalRequest
1617
+ SysApprovalAction,
1618
+ SysApprovalRequest,
1619
+ registerApprovalNode
1155
1620
  };
1156
1621
  //# sourceMappingURL=index.mjs.map