@objectstack/plugin-approvals 16.0.0 → 17.0.0-rc.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/.turbo/turbo-build.log +11 -11
- package/CHANGELOG.md +1037 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
package/dist/index.js
CHANGED
|
@@ -66,7 +66,8 @@ var init_en_objects_generated = __esm({
|
|
|
66
66
|
pending: "pending",
|
|
67
67
|
approved: "approved",
|
|
68
68
|
rejected: "rejected",
|
|
69
|
-
recalled: "recalled"
|
|
69
|
+
recalled: "recalled",
|
|
70
|
+
returned: "returned"
|
|
70
71
|
}
|
|
71
72
|
},
|
|
72
73
|
current_step: {
|
|
@@ -108,7 +109,11 @@ var init_en_objects_generated = __esm({
|
|
|
108
109
|
},
|
|
109
110
|
_views: {
|
|
110
111
|
my_pending: {
|
|
111
|
-
label: "My Pending"
|
|
112
|
+
label: "My Pending",
|
|
113
|
+
emptyState: {
|
|
114
|
+
title: "No pending approvals",
|
|
115
|
+
message: "You're all caught up."
|
|
116
|
+
}
|
|
112
117
|
},
|
|
113
118
|
submitted_by_me: {
|
|
114
119
|
label: "I Submitted"
|
|
@@ -119,6 +124,92 @@ var init_en_objects_generated = __esm({
|
|
|
119
124
|
all_requests: {
|
|
120
125
|
label: "All"
|
|
121
126
|
}
|
|
127
|
+
},
|
|
128
|
+
_actions: {
|
|
129
|
+
approval_approve: {
|
|
130
|
+
label: "Approve",
|
|
131
|
+
successMessage: "Approved.",
|
|
132
|
+
params: {
|
|
133
|
+
comment: {
|
|
134
|
+
label: "Comment"
|
|
135
|
+
},
|
|
136
|
+
attachments: {
|
|
137
|
+
label: "Attachments"
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
approval_reject: {
|
|
142
|
+
label: "Reject",
|
|
143
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
144
|
+
successMessage: "Rejected.",
|
|
145
|
+
params: {
|
|
146
|
+
comment: {
|
|
147
|
+
label: "Comment"
|
|
148
|
+
},
|
|
149
|
+
attachments: {
|
|
150
|
+
label: "Attachments"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
approval_reassign: {
|
|
155
|
+
label: "Reassign",
|
|
156
|
+
successMessage: "Reassigned.",
|
|
157
|
+
params: {
|
|
158
|
+
to: {
|
|
159
|
+
label: "New approver",
|
|
160
|
+
helpText: "User to hand this step to"
|
|
161
|
+
},
|
|
162
|
+
comment: {
|
|
163
|
+
label: "Comment"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
approval_send_back: {
|
|
168
|
+
label: "Send back",
|
|
169
|
+
successMessage: "Sent back for revision.",
|
|
170
|
+
params: {
|
|
171
|
+
comment: {
|
|
172
|
+
label: "Reason"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
approval_request_info: {
|
|
177
|
+
label: "Request info",
|
|
178
|
+
successMessage: "Information requested.",
|
|
179
|
+
params: {
|
|
180
|
+
comment: {
|
|
181
|
+
label: "What do you need?"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
approval_remind: {
|
|
186
|
+
label: "Send reminder",
|
|
187
|
+
successMessage: "Reminder sent.",
|
|
188
|
+
params: {
|
|
189
|
+
comment: {
|
|
190
|
+
label: "Note"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
approval_recall: {
|
|
195
|
+
label: "Recall",
|
|
196
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
197
|
+
successMessage: "Recalled.",
|
|
198
|
+
params: {
|
|
199
|
+
comment: {
|
|
200
|
+
label: "Comment"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
approval_resubmit: {
|
|
205
|
+
label: "Resubmit",
|
|
206
|
+
successMessage: "Resubmitted.",
|
|
207
|
+
params: {
|
|
208
|
+
comment: {
|
|
209
|
+
label: "What changed?"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
122
213
|
}
|
|
123
214
|
},
|
|
124
215
|
sys_approval_action: {
|
|
@@ -150,7 +241,14 @@ var init_en_objects_generated = __esm({
|
|
|
150
241
|
approve: "approve",
|
|
151
242
|
reject: "reject",
|
|
152
243
|
recall: "recall",
|
|
153
|
-
escalate: "escalate"
|
|
244
|
+
escalate: "escalate",
|
|
245
|
+
reassign: "reassign",
|
|
246
|
+
remind: "remind",
|
|
247
|
+
request_info: "request_info",
|
|
248
|
+
comment: "comment",
|
|
249
|
+
revise: "revise",
|
|
250
|
+
resubmit: "resubmit",
|
|
251
|
+
ooo_substitute: "ooo_substitute"
|
|
154
252
|
}
|
|
155
253
|
},
|
|
156
254
|
actor_id: {
|
|
@@ -159,13 +257,21 @@ var init_en_objects_generated = __esm({
|
|
|
159
257
|
comment: {
|
|
160
258
|
label: "Comment"
|
|
161
259
|
},
|
|
260
|
+
attachments: {
|
|
261
|
+
label: "Attachments",
|
|
262
|
+
help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
263
|
+
},
|
|
162
264
|
created_at: {
|
|
163
265
|
label: "Created At"
|
|
164
266
|
}
|
|
165
267
|
},
|
|
166
268
|
_views: {
|
|
167
269
|
recent: {
|
|
168
|
-
label: "Recent"
|
|
270
|
+
label: "Recent",
|
|
271
|
+
emptyState: {
|
|
272
|
+
title: "No approval actions yet",
|
|
273
|
+
message: "Actions are logged automatically when approvals progress."
|
|
274
|
+
}
|
|
169
275
|
},
|
|
170
276
|
by_actor: {
|
|
171
277
|
label: "By Actor"
|
|
@@ -269,7 +375,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
269
375
|
pending: "\u5F85\u5904\u7406",
|
|
270
376
|
approved: "\u5DF2\u6279\u51C6",
|
|
271
377
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
272
|
-
recalled: "\u5DF2\u64A4\u56DE"
|
|
378
|
+
recalled: "\u5DF2\u64A4\u56DE",
|
|
379
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
273
380
|
}
|
|
274
381
|
},
|
|
275
382
|
current_step: {
|
|
@@ -311,7 +418,11 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
311
418
|
},
|
|
312
419
|
_views: {
|
|
313
420
|
my_pending: {
|
|
314
|
-
label: "\u6211\u7684\u5F85\u529E"
|
|
421
|
+
label: "\u6211\u7684\u5F85\u529E",
|
|
422
|
+
emptyState: {
|
|
423
|
+
title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
|
|
424
|
+
message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
|
|
425
|
+
}
|
|
315
426
|
},
|
|
316
427
|
submitted_by_me: {
|
|
317
428
|
label: "\u6211\u63D0\u4EA4\u7684"
|
|
@@ -322,6 +433,92 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
322
433
|
all_requests: {
|
|
323
434
|
label: "\u5168\u90E8"
|
|
324
435
|
}
|
|
436
|
+
},
|
|
437
|
+
_actions: {
|
|
438
|
+
approval_approve: {
|
|
439
|
+
label: "\u901A\u8FC7",
|
|
440
|
+
successMessage: "\u5DF2\u901A\u8FC7\u3002",
|
|
441
|
+
params: {
|
|
442
|
+
comment: {
|
|
443
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
444
|
+
},
|
|
445
|
+
attachments: {
|
|
446
|
+
label: "\u9644\u4EF6"
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
approval_reject: {
|
|
451
|
+
label: "\u62D2\u7EDD",
|
|
452
|
+
confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
|
|
453
|
+
successMessage: "\u5DF2\u62D2\u7EDD\u3002",
|
|
454
|
+
params: {
|
|
455
|
+
comment: {
|
|
456
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
457
|
+
},
|
|
458
|
+
attachments: {
|
|
459
|
+
label: "\u9644\u4EF6"
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
approval_reassign: {
|
|
464
|
+
label: "\u8F6C\u7B7E",
|
|
465
|
+
successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
|
|
466
|
+
params: {
|
|
467
|
+
to: {
|
|
468
|
+
label: "\u65B0\u5BA1\u6279\u4EBA",
|
|
469
|
+
helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
|
|
470
|
+
},
|
|
471
|
+
comment: {
|
|
472
|
+
label: "\u5907\u6CE8"
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
},
|
|
476
|
+
approval_send_back: {
|
|
477
|
+
label: "\u9000\u56DE\u4FEE\u6539",
|
|
478
|
+
successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
|
|
479
|
+
params: {
|
|
480
|
+
comment: {
|
|
481
|
+
label: "\u539F\u56E0"
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
approval_request_info: {
|
|
486
|
+
label: "\u9000\u56DE\u8865\u5145",
|
|
487
|
+
successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
|
|
488
|
+
params: {
|
|
489
|
+
comment: {
|
|
490
|
+
label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
approval_remind: {
|
|
495
|
+
label: "\u50AC\u529E",
|
|
496
|
+
successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
|
|
497
|
+
params: {
|
|
498
|
+
comment: {
|
|
499
|
+
label: "\u5907\u6CE8"
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
},
|
|
503
|
+
approval_recall: {
|
|
504
|
+
label: "\u64A4\u56DE",
|
|
505
|
+
confirmText: "\u64A4\u56DE\u8BE5\u8BF7\u6C42\uFF1F\u64A4\u56DE\u540E\u5BA1\u6279\u4EBA\u5C06\u65E0\u6CD5\u7EE7\u7EED\u5904\u7406\uFF0C\u8BB0\u5F55\u968F\u5373\u89E3\u9501\u3002",
|
|
506
|
+
successMessage: "\u5DF2\u64A4\u56DE\u3002",
|
|
507
|
+
params: {
|
|
508
|
+
comment: {
|
|
509
|
+
label: "\u5907\u6CE8"
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
},
|
|
513
|
+
approval_resubmit: {
|
|
514
|
+
label: "\u91CD\u65B0\u63D0\u4EA4",
|
|
515
|
+
successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
|
|
516
|
+
params: {
|
|
517
|
+
comment: {
|
|
518
|
+
label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
325
522
|
}
|
|
326
523
|
},
|
|
327
524
|
sys_approval_action: {
|
|
@@ -353,7 +550,14 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
353
550
|
approve: "\u6279\u51C6",
|
|
354
551
|
reject: "\u62D2\u7EDD",
|
|
355
552
|
recall: "\u64A4\u56DE",
|
|
356
|
-
escalate: "\u5347\u7EA7"
|
|
553
|
+
escalate: "\u5347\u7EA7",
|
|
554
|
+
reassign: "\u8F6C\u7B7E",
|
|
555
|
+
remind: "\u50AC\u529E",
|
|
556
|
+
request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
|
|
557
|
+
comment: "\u8BC4\u8BBA",
|
|
558
|
+
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
559
|
+
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
560
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
357
561
|
}
|
|
358
562
|
},
|
|
359
563
|
actor_id: {
|
|
@@ -362,13 +566,21 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
362
566
|
comment: {
|
|
363
567
|
label: "\u8BC4\u8BBA"
|
|
364
568
|
},
|
|
569
|
+
attachments: {
|
|
570
|
+
label: "\u9644\u4EF6",
|
|
571
|
+
help: "\u652F\u6301\u8BE5\u64CD\u4F5C\u7684\u6587\u4EF6\u2014\u2014\u4F8B\u5982\u5DF2\u7B7E\u7F72\u7684\u5408\u540C\u6216\u8BC1\u660E\u6750\u6599\uFF08#3266\uFF09\u3002"
|
|
572
|
+
},
|
|
365
573
|
created_at: {
|
|
366
574
|
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
367
575
|
}
|
|
368
576
|
},
|
|
369
577
|
_views: {
|
|
370
578
|
recent: {
|
|
371
|
-
label: "\u6700\u8FD1"
|
|
579
|
+
label: "\u6700\u8FD1",
|
|
580
|
+
emptyState: {
|
|
581
|
+
title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
|
|
582
|
+
message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
|
|
583
|
+
}
|
|
372
584
|
},
|
|
373
585
|
by_actor: {
|
|
374
586
|
label: "\u6309\u6267\u884C\u4EBA"
|
|
@@ -472,7 +684,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
472
684
|
pending: "\u4FDD\u7559\u4E2D",
|
|
473
685
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
474
686
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
475
|
-
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
|
|
687
|
+
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
688
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
476
689
|
}
|
|
477
690
|
},
|
|
478
691
|
current_step: {
|
|
@@ -514,7 +727,11 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
514
727
|
},
|
|
515
728
|
_views: {
|
|
516
729
|
my_pending: {
|
|
517
|
-
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
|
|
730
|
+
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
|
|
731
|
+
emptyState: {
|
|
732
|
+
title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
|
|
733
|
+
message: "\u3059\u3079\u3066\u51E6\u7406\u6E08\u307F\u3067\u3059\u3002\u3042\u306A\u305F\u306E\u627F\u8A8D\u3092\u5F85\u3064\u30EA\u30AF\u30A8\u30B9\u30C8\u306F\u3042\u308A\u307E\u305B\u3093\u3002"
|
|
734
|
+
}
|
|
518
735
|
},
|
|
519
736
|
submitted_by_me: {
|
|
520
737
|
label: "\u81EA\u5206\u304C\u9001\u4FE1"
|
|
@@ -525,6 +742,92 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
525
742
|
all_requests: {
|
|
526
743
|
label: "\u3059\u3079\u3066"
|
|
527
744
|
}
|
|
745
|
+
},
|
|
746
|
+
_actions: {
|
|
747
|
+
approval_approve: {
|
|
748
|
+
label: "\u627F\u8A8D",
|
|
749
|
+
successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
|
|
750
|
+
params: {
|
|
751
|
+
comment: {
|
|
752
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
753
|
+
},
|
|
754
|
+
attachments: {
|
|
755
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
},
|
|
759
|
+
approval_reject: {
|
|
760
|
+
label: "\u5374\u4E0B",
|
|
761
|
+
confirmText: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u5374\u4E0B\u3057\u307E\u3059\u304B\uFF1F\u5374\u4E0B\u306F\u3059\u3079\u3066\u306E\u627F\u8A8D\u8005\u306B\u5BFE\u3057\u3066\u6700\u7D42\u6C7A\u5B9A\u306B\u306A\u308A\u307E\u3059\u3002",
|
|
762
|
+
successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
|
|
763
|
+
params: {
|
|
764
|
+
comment: {
|
|
765
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
766
|
+
},
|
|
767
|
+
attachments: {
|
|
768
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
approval_reassign: {
|
|
773
|
+
label: "\u518D\u5272\u308A\u5F53\u3066",
|
|
774
|
+
successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
|
|
775
|
+
params: {
|
|
776
|
+
to: {
|
|
777
|
+
label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
|
|
778
|
+
helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
|
|
779
|
+
},
|
|
780
|
+
comment: {
|
|
781
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
},
|
|
785
|
+
approval_send_back: {
|
|
786
|
+
label: "\u5DEE\u3057\u623B\u3057",
|
|
787
|
+
successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
|
|
788
|
+
params: {
|
|
789
|
+
comment: {
|
|
790
|
+
label: "\u7406\u7531"
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
approval_request_info: {
|
|
795
|
+
label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
|
|
796
|
+
successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
|
|
797
|
+
params: {
|
|
798
|
+
comment: {
|
|
799
|
+
label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
},
|
|
803
|
+
approval_remind: {
|
|
804
|
+
label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
|
|
805
|
+
successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
|
|
806
|
+
params: {
|
|
807
|
+
comment: {
|
|
808
|
+
label: "\u30E1\u30E2"
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
},
|
|
812
|
+
approval_recall: {
|
|
813
|
+
label: "\u53D6\u308A\u4E0B\u3052",
|
|
814
|
+
confirmText: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u53D6\u308A\u4E0B\u3052\u307E\u3059\u304B\uFF1F\u627F\u8A8D\u8005\u306F\u64CD\u4F5C\u3067\u304D\u306A\u304F\u306A\u308A\u3001\u30EC\u30B3\u30FC\u30C9\u306E\u30ED\u30C3\u30AF\u304C\u89E3\u9664\u3055\u308C\u307E\u3059\u3002",
|
|
815
|
+
successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
|
|
816
|
+
params: {
|
|
817
|
+
comment: {
|
|
818
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
approval_resubmit: {
|
|
823
|
+
label: "\u518D\u63D0\u51FA",
|
|
824
|
+
successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
|
|
825
|
+
params: {
|
|
826
|
+
comment: {
|
|
827
|
+
label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
}
|
|
528
831
|
}
|
|
529
832
|
},
|
|
530
833
|
sys_approval_action: {
|
|
@@ -556,7 +859,14 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
556
859
|
approve: "\u627F\u8A8D",
|
|
557
860
|
reject: "\u5374\u4E0B",
|
|
558
861
|
recall: "\u53D6\u6D88",
|
|
559
|
-
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
|
|
862
|
+
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
|
|
863
|
+
reassign: "\u518D\u5272\u308A\u5F53\u3066",
|
|
864
|
+
remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
|
|
865
|
+
request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
|
|
866
|
+
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
867
|
+
revise: "\u5DEE\u3057\u623B\u3057",
|
|
868
|
+
resubmit: "\u518D\u63D0\u51FA",
|
|
869
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
560
870
|
}
|
|
561
871
|
},
|
|
562
872
|
actor_id: {
|
|
@@ -565,13 +875,21 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
565
875
|
comment: {
|
|
566
876
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
567
877
|
},
|
|
878
|
+
attachments: {
|
|
879
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
|
|
880
|
+
help: "\u3053\u306E\u64CD\u4F5C\u3092\u88CF\u4ED8\u3051\u308B\u30D5\u30A1\u30A4\u30EB\u2014\u2014\u7F72\u540D\u6E08\u307F\u5951\u7D04\u66F8\u3084\u8A3C\u6191\u306A\u3069\uFF08#3266\uFF09\u3002"
|
|
881
|
+
},
|
|
568
882
|
created_at: {
|
|
569
883
|
label: "\u4F5C\u6210\u65E5\u6642"
|
|
570
884
|
}
|
|
571
885
|
},
|
|
572
886
|
_views: {
|
|
573
887
|
recent: {
|
|
574
|
-
label: "\u6700\u8FD1"
|
|
888
|
+
label: "\u6700\u8FD1",
|
|
889
|
+
emptyState: {
|
|
890
|
+
title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
|
|
891
|
+
message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
|
|
892
|
+
}
|
|
575
893
|
},
|
|
576
894
|
by_actor: {
|
|
577
895
|
label: "\u64CD\u4F5C\u8005\u5225"
|
|
@@ -675,7 +993,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
675
993
|
pending: "Pendiente",
|
|
676
994
|
approved: "Aprobada",
|
|
677
995
|
rejected: "Rechazada",
|
|
678
|
-
recalled: "Retirada"
|
|
996
|
+
recalled: "Retirada",
|
|
997
|
+
returned: "Devuelta para revisi\xF3n"
|
|
679
998
|
}
|
|
680
999
|
},
|
|
681
1000
|
current_step: {
|
|
@@ -717,7 +1036,11 @@ var init_es_ES_objects_generated = __esm({
|
|
|
717
1036
|
},
|
|
718
1037
|
_views: {
|
|
719
1038
|
my_pending: {
|
|
720
|
-
label: "Mis pendientes"
|
|
1039
|
+
label: "Mis pendientes",
|
|
1040
|
+
emptyState: {
|
|
1041
|
+
title: "Sin aprobaciones pendientes",
|
|
1042
|
+
message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
|
|
1043
|
+
}
|
|
721
1044
|
},
|
|
722
1045
|
submitted_by_me: {
|
|
723
1046
|
label: "Enviadas por m\xED"
|
|
@@ -728,6 +1051,92 @@ var init_es_ES_objects_generated = __esm({
|
|
|
728
1051
|
all_requests: {
|
|
729
1052
|
label: "Todas"
|
|
730
1053
|
}
|
|
1054
|
+
},
|
|
1055
|
+
_actions: {
|
|
1056
|
+
approval_approve: {
|
|
1057
|
+
label: "Aprobar",
|
|
1058
|
+
successMessage: "Aprobada.",
|
|
1059
|
+
params: {
|
|
1060
|
+
comment: {
|
|
1061
|
+
label: "Comentario"
|
|
1062
|
+
},
|
|
1063
|
+
attachments: {
|
|
1064
|
+
label: "Adjuntos"
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
},
|
|
1068
|
+
approval_reject: {
|
|
1069
|
+
label: "Rechazar",
|
|
1070
|
+
confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
|
|
1071
|
+
successMessage: "Rechazada.",
|
|
1072
|
+
params: {
|
|
1073
|
+
comment: {
|
|
1074
|
+
label: "Comentario"
|
|
1075
|
+
},
|
|
1076
|
+
attachments: {
|
|
1077
|
+
label: "Adjuntos"
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
},
|
|
1081
|
+
approval_reassign: {
|
|
1082
|
+
label: "Reasignar",
|
|
1083
|
+
successMessage: "Reasignada.",
|
|
1084
|
+
params: {
|
|
1085
|
+
to: {
|
|
1086
|
+
label: "Nuevo aprobador",
|
|
1087
|
+
helpText: "Usuario al que se entrega este paso"
|
|
1088
|
+
},
|
|
1089
|
+
comment: {
|
|
1090
|
+
label: "Comentario"
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
},
|
|
1094
|
+
approval_send_back: {
|
|
1095
|
+
label: "Devolver",
|
|
1096
|
+
successMessage: "Devuelta para revisi\xF3n.",
|
|
1097
|
+
params: {
|
|
1098
|
+
comment: {
|
|
1099
|
+
label: "Motivo"
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
},
|
|
1103
|
+
approval_request_info: {
|
|
1104
|
+
label: "Pedir informaci\xF3n",
|
|
1105
|
+
successMessage: "Informaci\xF3n solicitada.",
|
|
1106
|
+
params: {
|
|
1107
|
+
comment: {
|
|
1108
|
+
label: "\xBFQu\xE9 necesitas?"
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
},
|
|
1112
|
+
approval_remind: {
|
|
1113
|
+
label: "Enviar recordatorio",
|
|
1114
|
+
successMessage: "Recordatorio enviado.",
|
|
1115
|
+
params: {
|
|
1116
|
+
comment: {
|
|
1117
|
+
label: "Nota"
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
},
|
|
1121
|
+
approval_recall: {
|
|
1122
|
+
label: "Retirar",
|
|
1123
|
+
confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
|
|
1124
|
+
successMessage: "Retirada.",
|
|
1125
|
+
params: {
|
|
1126
|
+
comment: {
|
|
1127
|
+
label: "Comentario"
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
},
|
|
1131
|
+
approval_resubmit: {
|
|
1132
|
+
label: "Reenviar",
|
|
1133
|
+
successMessage: "Reenviada.",
|
|
1134
|
+
params: {
|
|
1135
|
+
comment: {
|
|
1136
|
+
label: "\xBFQu\xE9 ha cambiado?"
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
731
1140
|
}
|
|
732
1141
|
},
|
|
733
1142
|
sys_approval_action: {
|
|
@@ -759,7 +1168,14 @@ var init_es_ES_objects_generated = __esm({
|
|
|
759
1168
|
approve: "Aprobar",
|
|
760
1169
|
reject: "Rechazar",
|
|
761
1170
|
recall: "Retirar",
|
|
762
|
-
escalate: "Escalar"
|
|
1171
|
+
escalate: "Escalar",
|
|
1172
|
+
reassign: "Reasignaci\xF3n",
|
|
1173
|
+
remind: "Recordatorio",
|
|
1174
|
+
request_info: "Petici\xF3n de informaci\xF3n",
|
|
1175
|
+
comment: "Comentario",
|
|
1176
|
+
revise: "Devoluci\xF3n",
|
|
1177
|
+
resubmit: "Reenv\xEDo",
|
|
1178
|
+
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
763
1179
|
}
|
|
764
1180
|
},
|
|
765
1181
|
actor_id: {
|
|
@@ -768,13 +1184,21 @@ var init_es_ES_objects_generated = __esm({
|
|
|
768
1184
|
comment: {
|
|
769
1185
|
label: "Comentario"
|
|
770
1186
|
},
|
|
1187
|
+
attachments: {
|
|
1188
|
+
label: "Adjuntos",
|
|
1189
|
+
help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
|
|
1190
|
+
},
|
|
771
1191
|
created_at: {
|
|
772
1192
|
label: "Creado el"
|
|
773
1193
|
}
|
|
774
1194
|
},
|
|
775
1195
|
_views: {
|
|
776
1196
|
recent: {
|
|
777
|
-
label: "Recientes"
|
|
1197
|
+
label: "Recientes",
|
|
1198
|
+
emptyState: {
|
|
1199
|
+
title: "A\xFAn no hay acciones de aprobaci\xF3n",
|
|
1200
|
+
message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
|
|
1201
|
+
}
|
|
778
1202
|
},
|
|
779
1203
|
by_actor: {
|
|
780
1204
|
label: "Por actor"
|
|
@@ -1072,13 +1496,22 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1072
1496
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1073
1497
|
// (the caller is a current pending approver — same check the service
|
|
1074
1498
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1075
|
-
// submitter actions on `record.viewer.is_submitter`.
|
|
1076
|
-
//
|
|
1499
|
+
// submitter actions on `record.viewer.is_submitter`. The core decision levers
|
|
1500
|
+
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
|
|
1501
|
+
// (#3424) so a platform/tenant admin can rescue a request routed to an
|
|
1502
|
+
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1503
|
+
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1504
|
+
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1505
|
+
// closed.
|
|
1077
1506
|
actions: [
|
|
1078
1507
|
{
|
|
1079
1508
|
name: "approval_approve",
|
|
1080
1509
|
label: "Approve",
|
|
1081
1510
|
icon: "check-circle",
|
|
1511
|
+
// Primary decision — the console renders this filled/highlighted so it
|
|
1512
|
+
// stands out from the secondary levers in the drawer's action bar,
|
|
1513
|
+
// matching the mobile card hierarchy (objectui#2762 P1-5).
|
|
1514
|
+
variant: "primary",
|
|
1082
1515
|
type: "api",
|
|
1083
1516
|
method: "POST",
|
|
1084
1517
|
target: "/api/v1/approvals/requests/{id}/approve",
|
|
@@ -1089,7 +1522,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1089
1522
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1090
1523
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1091
1524
|
],
|
|
1092
|
-
visible: "record.viewer.can_act",
|
|
1525
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1093
1526
|
locations: ["record_section", "list_item"],
|
|
1094
1527
|
successMessage: "Approved.",
|
|
1095
1528
|
refreshAfter: true
|
|
@@ -1098,6 +1531,9 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1098
1531
|
name: "approval_reject",
|
|
1099
1532
|
label: "Reject",
|
|
1100
1533
|
icon: "x-circle",
|
|
1534
|
+
// Destructive decision — rendered in the console's danger styling so it
|
|
1535
|
+
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
1536
|
+
variant: "danger",
|
|
1101
1537
|
type: "api",
|
|
1102
1538
|
method: "POST",
|
|
1103
1539
|
target: "/api/v1/approvals/requests/{id}/reject",
|
|
@@ -1105,7 +1541,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1105
1541
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1106
1542
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1107
1543
|
],
|
|
1108
|
-
visible: "record.viewer.can_act",
|
|
1544
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1109
1545
|
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1110
1546
|
locations: ["record_section", "list_item"],
|
|
1111
1547
|
successMessage: "Rejected.",
|
|
@@ -1127,7 +1563,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1127
1563
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1128
1564
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1129
1565
|
],
|
|
1130
|
-
visible: "record.viewer.can_act",
|
|
1566
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1131
1567
|
locations: ["record_section"],
|
|
1132
1568
|
successMessage: "Reassigned.",
|
|
1133
1569
|
refreshAfter: true
|
|
@@ -1244,6 +1680,14 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1244
1680
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1245
1681
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1246
1682
|
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1683
|
+
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1684
|
+
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1685
|
+
// their download by testing whether the caller can READ this row. It cannot:
|
|
1686
|
+
// this table is deliberately closed to ordinary approver positions, so that
|
|
1687
|
+
// test denies the very approver the attachment was filed for. The approvals
|
|
1688
|
+
// service already owns the rule for seeing a decision (visibility of the
|
|
1689
|
+
// parent request, exactly as `listActions` applies it), so it answers.
|
|
1690
|
+
fileAccessDelegate: "approvals",
|
|
1247
1691
|
listViews: {
|
|
1248
1692
|
recent: {
|
|
1249
1693
|
type: "grid",
|
|
@@ -1495,23 +1939,166 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
|
1495
1939
|
trackHistory: true,
|
|
1496
1940
|
searchable: true,
|
|
1497
1941
|
apiEnabled: true,
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1942
|
+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
|
|
1943
|
+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
|
|
1944
|
+
apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
|
|
1501
1945
|
}
|
|
1502
1946
|
});
|
|
1503
1947
|
|
|
1504
1948
|
// src/approval-service.ts
|
|
1505
1949
|
var import_node_crypto = require("crypto");
|
|
1506
1950
|
var import_automation = require("@objectstack/spec/automation");
|
|
1951
|
+
var import_formula = require("@objectstack/formula");
|
|
1952
|
+
var import_identity = require("@objectstack/spec/identity");
|
|
1953
|
+
var import_contracts = require("@objectstack/spec/contracts");
|
|
1954
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
1507
1955
|
var import_core = require("@objectstack/core");
|
|
1956
|
+
|
|
1957
|
+
// src/approver-org-scope.ts
|
|
1958
|
+
var MAX_ORG_DEPTH = 32;
|
|
1959
|
+
var SYSTEM_CTX = { isSystem: true };
|
|
1960
|
+
function fail(message) {
|
|
1961
|
+
throw new Error(`VALIDATION_FAILED: ${message}`);
|
|
1962
|
+
}
|
|
1963
|
+
async function findOrg(engine, where) {
|
|
1964
|
+
try {
|
|
1965
|
+
const rows = await engine.find("sys_organization", {
|
|
1966
|
+
filter: where,
|
|
1967
|
+
fields: ["id", "slug", "parent_organization_id"],
|
|
1968
|
+
limit: 1,
|
|
1969
|
+
context: SYSTEM_CTX
|
|
1970
|
+
});
|
|
1971
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
1972
|
+
} catch {
|
|
1973
|
+
return null;
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
async function ancestorChain(engine, orgId) {
|
|
1977
|
+
const chain = [];
|
|
1978
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1979
|
+
let cursor = orgId;
|
|
1980
|
+
for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
|
|
1981
|
+
if (seen.has(cursor)) break;
|
|
1982
|
+
seen.add(cursor);
|
|
1983
|
+
chain.push(cursor);
|
|
1984
|
+
const row = await findOrg(engine, { id: cursor });
|
|
1985
|
+
const parent = row?.parent_organization_id;
|
|
1986
|
+
cursor = parent ? String(parent) : null;
|
|
1987
|
+
}
|
|
1988
|
+
return chain;
|
|
1989
|
+
}
|
|
1990
|
+
async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
|
|
1991
|
+
const declared = typeof declaration === "string" ? declaration.trim() : "";
|
|
1992
|
+
if (!declared) return requestOrgId;
|
|
1993
|
+
if (!isOrgScopedType) {
|
|
1994
|
+
fail(
|
|
1995
|
+
`approver type '${approverType}' resolves people without an organization directory, so 'organization: ${declared}' would have no effect \u2014 remove it (ADR-0105 D9 applies to position / org_membership_level / department / expression)`
|
|
1996
|
+
);
|
|
1997
|
+
}
|
|
1998
|
+
const posture = deps.posture?.();
|
|
1999
|
+
if (posture && posture !== "group") {
|
|
2000
|
+
fail(
|
|
2001
|
+
`cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
|
|
2002
|
+
);
|
|
2003
|
+
}
|
|
2004
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2005
|
+
if (!requestOrg) {
|
|
2006
|
+
fail(
|
|
2007
|
+
`'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
|
|
2008
|
+
);
|
|
2009
|
+
}
|
|
2010
|
+
const chain = await ancestorChain(deps.engine, requestOrg);
|
|
2011
|
+
if (declared === "$root") {
|
|
2012
|
+
const root = chain[chain.length - 1];
|
|
2013
|
+
if (root === requestOrg && chain.length === 1) {
|
|
2014
|
+
fail(
|
|
2015
|
+
`'organization: $root' resolved to the request's own organization \u2014 this organization has no 'parent_organization_id' lineage. Declare the group hierarchy (ADR-0105 D6) or drop the targeting`
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
return root;
|
|
2019
|
+
}
|
|
2020
|
+
if (declared === "$parent") {
|
|
2021
|
+
const parent = chain[1];
|
|
2022
|
+
if (!parent) {
|
|
2023
|
+
fail(
|
|
2024
|
+
`'organization: $parent' has no parent to resolve to \u2014 the request's organization is already the root of its group (ADR-0105 D6 'parent_organization_id')`
|
|
2025
|
+
);
|
|
2026
|
+
}
|
|
2027
|
+
return parent;
|
|
2028
|
+
}
|
|
2029
|
+
const target = await findOrg(deps.engine, { slug: declared });
|
|
2030
|
+
if (!target?.id) {
|
|
2031
|
+
fail(
|
|
2032
|
+
`no organization with slug '${declared}' \u2014 'organization' takes an organization SLUG or a symbol ($root / $parent), never an id (ids are per-deployment, flows are portable)`
|
|
2033
|
+
);
|
|
2034
|
+
}
|
|
2035
|
+
const targetId = String(target.id);
|
|
2036
|
+
if (targetId === requestOrg) return targetId;
|
|
2037
|
+
const targetChain = await ancestorChain(deps.engine, targetId);
|
|
2038
|
+
const targetRoot = targetChain[targetChain.length - 1];
|
|
2039
|
+
const requestRoot = chain[chain.length - 1];
|
|
2040
|
+
if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
|
|
2041
|
+
fail(
|
|
2042
|
+
`organization '${declared}' is not in the same group as the request's organization \u2014 cross-organization approval routes WITHIN one group (they must share a 'parent_organization_id' root, ADR-0105 D6/D9)`
|
|
2043
|
+
);
|
|
2044
|
+
}
|
|
2045
|
+
return targetId;
|
|
2046
|
+
}
|
|
2047
|
+
async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
2048
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2049
|
+
if (!requestOrg || userIds.length === 0) return userIds;
|
|
2050
|
+
let members = [];
|
|
2051
|
+
try {
|
|
2052
|
+
members = await deps.engine.find("sys_member", {
|
|
2053
|
+
filter: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
2054
|
+
fields: ["user_id"],
|
|
2055
|
+
limit: 1e4,
|
|
2056
|
+
context: SYSTEM_CTX
|
|
2057
|
+
});
|
|
2058
|
+
} catch {
|
|
2059
|
+
return userIds;
|
|
2060
|
+
}
|
|
2061
|
+
const canRead = new Set(
|
|
2062
|
+
(members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
|
|
2063
|
+
);
|
|
2064
|
+
const dropped = userIds.filter((u) => !canRead.has(u));
|
|
2065
|
+
if (dropped.length === 0) return userIds;
|
|
2066
|
+
deps.logger?.warn?.(
|
|
2067
|
+
`[approvals] ADR-0105 D9: ${dropped.length} cross-organization approver(s) dropped \u2014 they hold '${context.value ?? context.approverType}' in organization '${context.directoryOrgId}' but no membership in the request's organization '${requestOrg}', so the D2 union wall would hide the request from them. Grant those users a membership in the request's organization, or retarget the approver.`,
|
|
2068
|
+
{ dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
|
|
2069
|
+
);
|
|
2070
|
+
return userIds.filter((u) => canRead.has(u));
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
// src/approval-service.ts
|
|
1508
2074
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1509
2075
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1510
2076
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1511
2077
|
var SLA_ACTOR_ID = "system:sla";
|
|
2078
|
+
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2079
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2080
|
+
"completed",
|
|
2081
|
+
"failed",
|
|
2082
|
+
"cancelled",
|
|
2083
|
+
"timed_out"
|
|
2084
|
+
]);
|
|
1512
2085
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1513
|
-
var
|
|
2086
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2087
|
+
function actingUserId(context) {
|
|
2088
|
+
const userId = context?.userId;
|
|
2089
|
+
return typeof userId === "string" && userId ? userId : null;
|
|
2090
|
+
}
|
|
1514
2091
|
var OOO_MAX_CHAIN = 8;
|
|
2092
|
+
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2093
|
+
"team",
|
|
2094
|
+
"department",
|
|
2095
|
+
"business_unit",
|
|
2096
|
+
"bu",
|
|
2097
|
+
"position",
|
|
2098
|
+
"org_membership_level",
|
|
2099
|
+
"manager"
|
|
2100
|
+
]);
|
|
2101
|
+
var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
1515
2102
|
function uid(prefix) {
|
|
1516
2103
|
const g = globalThis;
|
|
1517
2104
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1565,7 +2152,26 @@ function rowFromRequest(row) {
|
|
|
1565
2152
|
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
|
|
1566
2153
|
sla_due_at: slaDueAt(row.created_at, cfg),
|
|
1567
2154
|
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
|
|
1568
|
-
round: typeof cfg?.__round === "number" ? cfg.__round : void 0
|
|
2155
|
+
round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
|
|
2156
|
+
// objectui#2902: the node's record-lock policy. The lock is enforced
|
|
2157
|
+
// server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
|
|
2158
|
+
// same `!== false` default, so the flag a client renders and the rule the
|
|
2159
|
+
// server applies can never drift. Without it a console can only see
|
|
2160
|
+
// "a pending request exists" and has to assume the record is locked —
|
|
2161
|
+
// which mislabels every `lockRecord: false` node as locked and hides an
|
|
2162
|
+
// edit the server would have accepted.
|
|
2163
|
+
lock_record: cfg?.lockRecord !== false,
|
|
2164
|
+
// #3447 P2: the node's author-declared decision outputs, surfaced so a
|
|
2165
|
+
// decision UI can render input fields for them and POST `outputs` on
|
|
2166
|
+
// approve/reject. Per-request (each node declares its own), which is why
|
|
2167
|
+
// this rides the row instead of the static action params. Two shapes for
|
|
2168
|
+
// version skew: `decision_outputs` stays the bare KEY list an older
|
|
2169
|
+
// console renders as text inputs; `decision_output_defs` carries the
|
|
2170
|
+
// normalized typed declarations a picker-aware console prefers.
|
|
2171
|
+
...(() => {
|
|
2172
|
+
const defs = (0, import_automation.normalizeDecisionOutputs)(cfg?.decisionOutputs);
|
|
2173
|
+
return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
|
|
2174
|
+
})()
|
|
1569
2175
|
};
|
|
1570
2176
|
}
|
|
1571
2177
|
function slaDueAt(createdAt, cfg) {
|
|
@@ -1575,7 +2181,29 @@ function slaDueAt(createdAt, cfg) {
|
|
|
1575
2181
|
if (Number.isNaN(t)) return void 0;
|
|
1576
2182
|
return new Date(t + hours * 36e5).toISOString();
|
|
1577
2183
|
}
|
|
2184
|
+
function normalizeActionAttachment(entry) {
|
|
2185
|
+
if (entry == null) return void 0;
|
|
2186
|
+
if (typeof entry === "string") {
|
|
2187
|
+
const id = entry.trim();
|
|
2188
|
+
if (!id) return void 0;
|
|
2189
|
+
return (0, import_data5.isFileIdToken)(id) ? { id } : { id, url: id };
|
|
2190
|
+
}
|
|
2191
|
+
if (typeof entry === "object") {
|
|
2192
|
+
const id = entry.id ?? entry.file_id;
|
|
2193
|
+
if (id == null || String(id) === "") return void 0;
|
|
2194
|
+
const mimeType = entry.mimeType ?? entry.mime_type;
|
|
2195
|
+
return {
|
|
2196
|
+
id: String(id),
|
|
2197
|
+
name: typeof entry.name === "string" ? entry.name : void 0,
|
|
2198
|
+
url: typeof entry.url === "string" ? entry.url : void 0,
|
|
2199
|
+
mimeType: typeof mimeType === "string" ? mimeType : void 0,
|
|
2200
|
+
size: typeof entry.size === "number" ? entry.size : void 0
|
|
2201
|
+
};
|
|
2202
|
+
}
|
|
2203
|
+
return void 0;
|
|
2204
|
+
}
|
|
1578
2205
|
function rowFromAction(row) {
|
|
2206
|
+
const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
|
|
1579
2207
|
return {
|
|
1580
2208
|
id: String(row.id),
|
|
1581
2209
|
request_id: String(row.request_id),
|
|
@@ -1584,10 +2212,9 @@ function rowFromAction(row) {
|
|
|
1584
2212
|
action: row.action,
|
|
1585
2213
|
actor_id: row.actor_id ?? void 0,
|
|
1586
2214
|
comment: row.comment ?? void 0,
|
|
1587
|
-
// Decision attachments (#3266)
|
|
1588
|
-
//
|
|
1589
|
-
|
|
1590
|
-
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
|
|
2215
|
+
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2216
|
+
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2217
|
+
attachments: attachments.length ? attachments : void 0,
|
|
1591
2218
|
created_at: row.created_at ?? void 0
|
|
1592
2219
|
};
|
|
1593
2220
|
}
|
|
@@ -1599,6 +2226,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1599
2226
|
this.automation = opts.automation;
|
|
1600
2227
|
this.messaging = opts.messaging;
|
|
1601
2228
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2229
|
+
this.tenancyPosture = opts.tenancyPosture;
|
|
2230
|
+
}
|
|
2231
|
+
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2232
|
+
attachTenancyPosture(provider) {
|
|
2233
|
+
this.tenancyPosture = provider;
|
|
2234
|
+
}
|
|
2235
|
+
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2236
|
+
get orgScopeDeps() {
|
|
2237
|
+
return {
|
|
2238
|
+
engine: this.engine,
|
|
2239
|
+
posture: this.tenancyPosture,
|
|
2240
|
+
logger: this.logger
|
|
2241
|
+
};
|
|
2242
|
+
}
|
|
2243
|
+
/**
|
|
2244
|
+
* [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
|
|
2245
|
+
* Absent declaration ⇒ the request's own organization (unchanged, no reads).
|
|
2246
|
+
*/
|
|
2247
|
+
async directoryOrgFor(a, requestOrgId) {
|
|
2248
|
+
const rawType = String(a?.type ?? "");
|
|
2249
|
+
return resolveApproverDirectoryOrg(
|
|
2250
|
+
this.orgScopeDeps,
|
|
2251
|
+
a?.organization,
|
|
2252
|
+
requestOrgId,
|
|
2253
|
+
rawType,
|
|
2254
|
+
(0, import_automation.approverTypeIsOrgScoped)(rawType)
|
|
2255
|
+
);
|
|
1602
2256
|
}
|
|
1603
2257
|
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
1604
2258
|
attachAutomation(automation) {
|
|
@@ -1633,13 +2287,107 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1633
2287
|
const rows = await this.engine.find("sys_approval_request", {
|
|
1634
2288
|
where: { id: requestId },
|
|
1635
2289
|
limit: 1,
|
|
1636
|
-
context:
|
|
2290
|
+
context: SYSTEM_CTX2
|
|
1637
2291
|
});
|
|
1638
2292
|
const raw = Array.isArray(rows) ? rows[0] : null;
|
|
1639
2293
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
1640
2294
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
1641
2295
|
return raw;
|
|
1642
2296
|
}
|
|
2297
|
+
/**
|
|
2298
|
+
* Privileged-override gate (#3424). A stuck approval — one routed to a
|
|
2299
|
+
* position/team with no holders (so its `pending_approvers` is only an
|
|
2300
|
+
* unresolvable `type:value` literal) or to approvers who have all since left —
|
|
2301
|
+
* is otherwise undecidable: no concrete user is in the slate, so every normal
|
|
2302
|
+
* `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
|
|
2303
|
+
* record stays locked forever with no in-product recovery. A platform or
|
|
2304
|
+
* tenant admin — the same posture the engine's superuser bypass already
|
|
2305
|
+
* trusts — may always act on a PENDING request to release it: approve, reject,
|
|
2306
|
+
* reassign it to a real approver, or recall it.
|
|
2307
|
+
*
|
|
2308
|
+
* A platform admin crosses the tenant wall (matching the unscoped
|
|
2309
|
+
* `admin_full_access` evidence); a tenant admin may override only within their
|
|
2310
|
+
* own org (or an org-less request). A system context always passes. Signals are
|
|
2311
|
+
* read defensively off the resolved exec context (`permissions` / `positions` /
|
|
2312
|
+
* the derived `posture`, ADR-0095) so any transport that resolves through the
|
|
2313
|
+
* shared authz resolver lights this up without extra wiring.
|
|
2314
|
+
*/
|
|
2315
|
+
isOverrideActor(context, requestOrg) {
|
|
2316
|
+
if (!context) return false;
|
|
2317
|
+
if (context.isSystem) return true;
|
|
2318
|
+
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
2319
|
+
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
2320
|
+
const posture = context.posture;
|
|
2321
|
+
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS) || positions.includes(import_identity.BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
2322
|
+
if (isPlatformAdmin) return true;
|
|
2323
|
+
const isTenantAdmin = posture === "TENANT_ADMIN" || import_identity.ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_ADMIN);
|
|
2324
|
+
if (!isTenantAdmin) return false;
|
|
2325
|
+
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2326
|
+
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2330
|
+
*
|
|
2331
|
+
* Every public entrypoint accepts an `actorId`, and the REST routes fill it
|
|
2332
|
+
* from `body.actorId ?? body.actor_id ?? context.userId` — so before this
|
|
2333
|
+
* gate the body won. The authorization checks downstream all read that value
|
|
2334
|
+
* (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
|
|
2335
|
+
* which made the body-supplied string not merely the audit label but the key
|
|
2336
|
+
* that opens the door: any authenticated user could name a pending approver
|
|
2337
|
+
* and have that approver's decision recorded and the owning flow resumed.
|
|
2338
|
+
* #3783 drew this line for the data-write identity ({@link actingUserId});
|
|
2339
|
+
* this closes the authorization half.
|
|
2340
|
+
*
|
|
2341
|
+
* A caller may still name an identity OTHER than their bare user id, because
|
|
2342
|
+
* a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
|
|
2343
|
+
* `type:value` literal when a graph lookup yields nothing, and an author may
|
|
2344
|
+
* write an email as a `user` approver. So the rule is not "actorId must equal
|
|
2345
|
+
* userId" — it is **"actorId must be an identity the SERVER can prove belongs
|
|
2346
|
+
* to the caller"**. Anything else is `FORBIDDEN`.
|
|
2347
|
+
*
|
|
2348
|
+
* A system context is exempt and keeps its explicit actor: the SLA sweep
|
|
2349
|
+
* passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
|
|
2350
|
+
* link passes the approver its single-use token is cryptographically bound to
|
|
2351
|
+
* (having also put them on the context). Those are the only two callers that
|
|
2352
|
+
* hold a trustworthy actor with no session behind them.
|
|
2353
|
+
*
|
|
2354
|
+
* A caller with NO identity at all cannot act. That case is reachable: the
|
|
2355
|
+
* REST anonymous-deny only fires when `api.requireAuth` is set, so without it
|
|
2356
|
+
* an anonymous request previously decided approvals outright by naming one.
|
|
2357
|
+
*/
|
|
2358
|
+
async resolveActor(actorId, context) {
|
|
2359
|
+
if (context?.isSystem) {
|
|
2360
|
+
if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
2361
|
+
return actorId;
|
|
2362
|
+
}
|
|
2363
|
+
const uid2 = actingUserId(context);
|
|
2364
|
+
if (!uid2) {
|
|
2365
|
+
throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
|
|
2366
|
+
}
|
|
2367
|
+
if (!actorId || String(actorId) === uid2) return uid2;
|
|
2368
|
+
const named = String(actorId);
|
|
2369
|
+
for (const position of context.positions ?? []) {
|
|
2370
|
+
if (named === `position:${position}` || named === `role:${position}`) return named;
|
|
2371
|
+
}
|
|
2372
|
+
if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
|
|
2373
|
+
throw new Error(
|
|
2374
|
+
`FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
|
|
2375
|
+
);
|
|
2376
|
+
}
|
|
2377
|
+
/** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
|
|
2378
|
+
async callerHasEmail(userId, email) {
|
|
2379
|
+
try {
|
|
2380
|
+
const rows = await this.engine.find("sys_user", {
|
|
2381
|
+
where: { id: userId },
|
|
2382
|
+
limit: 1,
|
|
2383
|
+
context: SYSTEM_CTX2
|
|
2384
|
+
});
|
|
2385
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2386
|
+
return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
|
|
2387
|
+
} catch {
|
|
2388
|
+
return false;
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
1643
2391
|
/**
|
|
1644
2392
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1645
2393
|
* graph tables for `team:` / `department:` / `position:` /
|
|
@@ -1674,7 +2422,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1674
2422
|
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1675
2423
|
*/
|
|
1676
2424
|
async expandApprovers(step, record, organizationId, opts) {
|
|
1677
|
-
var _a;
|
|
2425
|
+
var _a, _b, _c;
|
|
1678
2426
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1679
2427
|
const now = opts?.now ?? this.clock.now().getTime();
|
|
1680
2428
|
const out = [];
|
|
@@ -1682,12 +2430,34 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1682
2430
|
for (let idx = 0; idx < specs.length; idx++) {
|
|
1683
2431
|
const a = specs[idx];
|
|
1684
2432
|
if (!a) continue;
|
|
1685
|
-
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1686
2433
|
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
2434
|
+
if ((0, import_automation.canonicalApproverType)(String(a.type)) === "expression") {
|
|
2435
|
+
const resolved = await this.resolveExpressionApprovers(
|
|
2436
|
+
a,
|
|
2437
|
+
record,
|
|
2438
|
+
organizationId,
|
|
2439
|
+
now,
|
|
2440
|
+
opts?.substitutions,
|
|
2441
|
+
opts?.exprCtx
|
|
2442
|
+
);
|
|
2443
|
+
if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
|
|
2444
|
+
for (const entry of resolved.slots) {
|
|
2445
|
+
if (!entry.id) continue;
|
|
2446
|
+
out.push(entry.id);
|
|
2447
|
+
if (opts?.groups) {
|
|
2448
|
+
((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
continue;
|
|
2452
|
+
}
|
|
2453
|
+
if (opts?.resolvedFrom && (0, import_automation.canonicalApproverType)(String(a.type)) === "field" && a.value != null) {
|
|
2454
|
+
opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
|
|
2455
|
+
}
|
|
2456
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1687
2457
|
for (const u of ids) {
|
|
1688
2458
|
if (!u) continue;
|
|
1689
2459
|
out.push(u);
|
|
1690
|
-
if (opts?.groups) ((
|
|
2460
|
+
if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
|
|
1691
2461
|
}
|
|
1692
2462
|
}
|
|
1693
2463
|
return out.filter(Boolean);
|
|
@@ -1708,24 +2478,35 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1708
2478
|
{ deprecated: a.type, canonical: type }
|
|
1709
2479
|
);
|
|
1710
2480
|
}
|
|
2481
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2482
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
1711
2483
|
if (type === "user") {
|
|
1712
2484
|
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1713
2485
|
}
|
|
1714
2486
|
if (type === "field" && record) {
|
|
1715
|
-
|
|
2487
|
+
const out = [];
|
|
2488
|
+
for (const id of csvSplit(record[a.value])) {
|
|
2489
|
+
out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
|
|
2490
|
+
}
|
|
2491
|
+
return out;
|
|
1716
2492
|
}
|
|
2493
|
+
const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2494
|
+
approverType: type,
|
|
2495
|
+
value: a.value != null ? String(a.value) : void 0,
|
|
2496
|
+
directoryOrgId: directoryOrg
|
|
2497
|
+
}) : users;
|
|
1717
2498
|
try {
|
|
1718
2499
|
if (type === "team") {
|
|
1719
2500
|
const users = await this.expandTeamUsers(String(a.value));
|
|
1720
2501
|
if (users.length) return users;
|
|
1721
2502
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1722
|
-
const users = await this.expandBusinessUnitUsers(String(a.value),
|
|
2503
|
+
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
1723
2504
|
if (users.length) return users;
|
|
1724
2505
|
} else if (type === "position") {
|
|
1725
|
-
const users = await this.expandPositionUsers(String(a.value),
|
|
2506
|
+
const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
|
|
1726
2507
|
if (users.length) return users;
|
|
1727
2508
|
} else if (type === "org_membership_level") {
|
|
1728
|
-
const users = await this.expandMembershipTierUsers(String(a.value),
|
|
2509
|
+
const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
|
|
1729
2510
|
if (users.length) return users;
|
|
1730
2511
|
} else if (type === "manager" && record) {
|
|
1731
2512
|
const subject = record[a.value] ?? record.owner_id;
|
|
@@ -1736,8 +2517,122 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1736
2517
|
}
|
|
1737
2518
|
} catch {
|
|
1738
2519
|
}
|
|
2520
|
+
if (type === "queue") {
|
|
2521
|
+
this.logger?.warn?.(
|
|
2522
|
+
`[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
|
|
2523
|
+
{ value: a.value }
|
|
2524
|
+
);
|
|
2525
|
+
} else if (GRAPH_APPROVER_TYPES.has(type)) {
|
|
2526
|
+
this.logger?.warn?.(
|
|
2527
|
+
`[approvals] approver '${type}:${a.value}' expanded to nobody \u2014 the slot routes to no one and the request cannot advance until someone is added or the approver is re-pointed (#3807)`,
|
|
2528
|
+
{ type, value: a.value, organizationId: organizationId ?? null }
|
|
2529
|
+
);
|
|
2530
|
+
}
|
|
1739
2531
|
return [`${a.type}:${a.value}`];
|
|
1740
2532
|
}
|
|
2533
|
+
/**
|
|
2534
|
+
* Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
|
|
2535
|
+
* node entry against the three explicit roots — `current` (live record),
|
|
2536
|
+
* `trigger` (submit snapshot), `vars` (flow variables) — then expand the
|
|
2537
|
+
* result into people per `resolveAs`.
|
|
2538
|
+
*
|
|
2539
|
+
* Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
|
|
2540
|
+
* evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
|
|
2541
|
+
* loudly instead of opening a request routed to nobody — an approver
|
|
2542
|
+
* expression that cannot run is a routing bug, never "condition not met".
|
|
2543
|
+
* Error messages carry the correct spelling because their primary reader is
|
|
2544
|
+
* the AI author fixing the flow on the next validate pass.
|
|
2545
|
+
*
|
|
2546
|
+
* Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
|
|
2547
|
+
* expression's own values, pre-expansion) for the `__resolvedFrom` audit.
|
|
2548
|
+
*/
|
|
2549
|
+
async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
|
|
2550
|
+
const source = String(a.value ?? "").trim();
|
|
2551
|
+
if (!source) {
|
|
2552
|
+
throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
|
|
2553
|
+
}
|
|
2554
|
+
const parsed = (0, import_formula.collectCelRootIdentifiers)(source);
|
|
2555
|
+
if (!parsed.ok) {
|
|
2556
|
+
throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
|
|
2557
|
+
}
|
|
2558
|
+
const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
|
|
2559
|
+
if (illegal.length) {
|
|
2560
|
+
const hint = illegal.includes("record") || illegal.includes("previous") ? `\`record\`/\`previous\` are not bound here \u2014 write \`current.<field>\` for the record's live state at node entry, or \`trigger.<field>\` for the submit-time snapshot (\`vars.previous\` carries the pre-update row)` : `did you mean \`current.<field>\` (live record), \`trigger.<field>\` (submit snapshot), or \`vars.<name>\` (flow variable)?`;
|
|
2561
|
+
throw new Error(
|
|
2562
|
+
`VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
|
|
2563
|
+
);
|
|
2564
|
+
}
|
|
2565
|
+
const result = import_formula.ExpressionEngine.evaluate(
|
|
2566
|
+
{ dialect: "cel", source },
|
|
2567
|
+
{ extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
|
|
2568
|
+
);
|
|
2569
|
+
if (!result.ok) {
|
|
2570
|
+
throw new Error(
|
|
2571
|
+
`EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
|
|
2572
|
+
);
|
|
2573
|
+
}
|
|
2574
|
+
const value = result.value;
|
|
2575
|
+
let raw;
|
|
2576
|
+
if (value == null || value === "") {
|
|
2577
|
+
raw = [];
|
|
2578
|
+
} else if (typeof value === "string") {
|
|
2579
|
+
raw = csvSplit(value);
|
|
2580
|
+
} else if (Array.isArray(value)) {
|
|
2581
|
+
const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
|
|
2582
|
+
if (bad !== void 0) {
|
|
2583
|
+
throw new Error(
|
|
2584
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
|
|
2585
|
+
);
|
|
2586
|
+
}
|
|
2587
|
+
raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
|
|
2588
|
+
} else {
|
|
2589
|
+
throw new Error(
|
|
2590
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
2593
|
+
const resolveAs = String(a.resolveAs ?? "user");
|
|
2594
|
+
if (resolveAs === "user") {
|
|
2595
|
+
const slots2 = [];
|
|
2596
|
+
for (const id of raw) {
|
|
2597
|
+
for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
|
|
2598
|
+
slots2.push({ id: routed });
|
|
2599
|
+
}
|
|
2600
|
+
}
|
|
2601
|
+
return { slots: slots2, raw };
|
|
2602
|
+
}
|
|
2603
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2604
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
2605
|
+
const slots = [];
|
|
2606
|
+
for (const key of raw) {
|
|
2607
|
+
let users = [];
|
|
2608
|
+
try {
|
|
2609
|
+
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2610
|
+
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2611
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2612
|
+
else {
|
|
2613
|
+
throw new Error(
|
|
2614
|
+
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
2615
|
+
);
|
|
2616
|
+
}
|
|
2617
|
+
} catch (err) {
|
|
2618
|
+
if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
|
|
2619
|
+
users = [];
|
|
2620
|
+
}
|
|
2621
|
+
if (crossOrg && users.length) {
|
|
2622
|
+
users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2623
|
+
approverType: resolveAs,
|
|
2624
|
+
value: key,
|
|
2625
|
+
directoryOrgId: directoryOrg
|
|
2626
|
+
});
|
|
2627
|
+
}
|
|
2628
|
+
if (!users.length) {
|
|
2629
|
+
slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
|
|
2630
|
+
continue;
|
|
2631
|
+
}
|
|
2632
|
+
for (const u of users) slots.push({ id: u, subGroup: key });
|
|
2633
|
+
}
|
|
2634
|
+
return { slots, raw };
|
|
2635
|
+
}
|
|
1741
2636
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1742
2637
|
async expandTeamUsers(teamId) {
|
|
1743
2638
|
if (!teamId) return [];
|
|
@@ -1747,22 +2642,46 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1747
2642
|
filter: { team_id: teamId },
|
|
1748
2643
|
fields: ["user_id"],
|
|
1749
2644
|
limit: 1e4,
|
|
1750
|
-
context:
|
|
2645
|
+
context: SYSTEM_CTX2
|
|
1751
2646
|
});
|
|
1752
2647
|
} catch {
|
|
1753
2648
|
rows = [];
|
|
1754
2649
|
}
|
|
1755
2650
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
1756
2651
|
}
|
|
2652
|
+
/**
|
|
2653
|
+
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
2654
|
+
* env-wide (#3807).
|
|
2655
|
+
*
|
|
2656
|
+
* `organization_id = null` on a platform object means "owned by no
|
|
2657
|
+
* organization" — a row written by a seed, the file layer, or bootstrap,
|
|
2658
|
+
* i.e. before (or outside) any org exists. A strict
|
|
2659
|
+
* `organization_id = <request org>` equality made every such row invisible:
|
|
2660
|
+
* the seed check below found nothing, the whole expansion returned `[]`, and
|
|
2661
|
+
* the approver fell back to the dead `department:<id>` literal that routes to
|
|
2662
|
+
* nobody. That is not an edge case — an app's org tree is normally seeded
|
|
2663
|
+
* (a seed cannot know the org id the runtime mints at boot) while the
|
|
2664
|
+
* approval request always carries one, so EVERY department approver a
|
|
2665
|
+
* designer could pick resolved to nobody.
|
|
2666
|
+
*
|
|
2667
|
+
* Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
|
|
2668
|
+
* pending-draft listing settled on for the identical reason. Another org's
|
|
2669
|
+
* unit still fails the match, so the wall between two organizations is
|
|
2670
|
+
* unchanged — only rows belonging to no org at all become visible.
|
|
2671
|
+
*/
|
|
2672
|
+
businessUnitOrgScope(filter, organizationId) {
|
|
2673
|
+
if (!organizationId) return filter;
|
|
2674
|
+
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
2675
|
+
}
|
|
1757
2676
|
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
|
|
1758
2677
|
async expandBusinessUnitUsers(businessUnitId, organizationId) {
|
|
1759
2678
|
if (!businessUnitId) return [];
|
|
1760
2679
|
try {
|
|
1761
2680
|
const seed = await this.engine.find("sys_business_unit", {
|
|
1762
|
-
filter:
|
|
2681
|
+
filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
1763
2682
|
fields: ["id", "active"],
|
|
1764
2683
|
limit: 1,
|
|
1765
|
-
context:
|
|
2684
|
+
context: SYSTEM_CTX2
|
|
1766
2685
|
});
|
|
1767
2686
|
const seedRow = Array.isArray(seed) ? seed[0] : null;
|
|
1768
2687
|
if (!seedRow || seedRow.active === false) return [];
|
|
@@ -1775,9 +2694,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1775
2694
|
const parent = queue.shift();
|
|
1776
2695
|
let kids = [];
|
|
1777
2696
|
try {
|
|
1778
|
-
const filter =
|
|
1779
|
-
|
|
1780
|
-
|
|
2697
|
+
const filter = this.businessUnitOrgScope(
|
|
2698
|
+
{ parent_business_unit_id: parent, active: { $ne: false } },
|
|
2699
|
+
organizationId
|
|
2700
|
+
);
|
|
2701
|
+
kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
|
|
1781
2702
|
} catch {
|
|
1782
2703
|
kids = [];
|
|
1783
2704
|
}
|
|
@@ -1795,7 +2716,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1795
2716
|
filter: { business_unit_id: { $in: Array.from(seen) } },
|
|
1796
2717
|
fields: ["user_id"],
|
|
1797
2718
|
limit: 1e4,
|
|
1798
|
-
context:
|
|
2719
|
+
context: SYSTEM_CTX2
|
|
1799
2720
|
});
|
|
1800
2721
|
} catch {
|
|
1801
2722
|
rows = [];
|
|
@@ -1820,7 +2741,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1820
2741
|
filter,
|
|
1821
2742
|
fields: ["user_id"],
|
|
1822
2743
|
limit: 1e4,
|
|
1823
|
-
context:
|
|
2744
|
+
context: SYSTEM_CTX2
|
|
1824
2745
|
});
|
|
1825
2746
|
for (const r of rows ?? []) {
|
|
1826
2747
|
const uid2 = String(r.user_id ?? "");
|
|
@@ -1843,7 +2764,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1843
2764
|
if (organizationId) filter.organization_id = organizationId;
|
|
1844
2765
|
let rows = [];
|
|
1845
2766
|
try {
|
|
1846
|
-
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context:
|
|
2767
|
+
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
|
|
1847
2768
|
} catch {
|
|
1848
2769
|
rows = [];
|
|
1849
2770
|
}
|
|
@@ -1855,7 +2776,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1855
2776
|
filter: { id: userId },
|
|
1856
2777
|
fields: ["id", "manager_id"],
|
|
1857
2778
|
limit: 1,
|
|
1858
|
-
context:
|
|
2779
|
+
context: SYSTEM_CTX2
|
|
1859
2780
|
});
|
|
1860
2781
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
1861
2782
|
return row?.manager_id ? String(row.manager_id) : null;
|
|
@@ -1905,7 +2826,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1905
2826
|
filter: { delegator_id: delegatorId },
|
|
1906
2827
|
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1907
2828
|
limit: 50,
|
|
1908
|
-
context:
|
|
2829
|
+
context: SYSTEM_CTX2
|
|
1909
2830
|
});
|
|
1910
2831
|
} catch {
|
|
1911
2832
|
return null;
|
|
@@ -1919,14 +2840,86 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1919
2840
|
});
|
|
1920
2841
|
return active[0];
|
|
1921
2842
|
}
|
|
1922
|
-
/**
|
|
1923
|
-
|
|
2843
|
+
/**
|
|
2844
|
+
* Mirror a request status onto a business-object field, if configured.
|
|
2845
|
+
*
|
|
2846
|
+
* **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
|
|
2847
|
+
* record is normally LOCKED while its approval is live and the submitter
|
|
2848
|
+
* cannot edit it, so only a platform write can land the status — that is what
|
|
2849
|
+
* the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
|
|
2850
|
+
* NOT do is throw away *who* caused the transition. Every status below is
|
|
2851
|
+
* something a specific human just did — a submitter submitting or recalling,
|
|
2852
|
+
* an approver deciding or sending back — and this write is what fires the
|
|
2853
|
+
* target object's record-change flows. With no `userId` on it those cascades
|
|
2854
|
+
* inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
|
|
2855
|
+
* user has its data ops REFUSED — so "when the invoice is approved, do X", the
|
|
2856
|
+
* most natural approvals automation there is, had to declare `runAs:'system'`
|
|
2857
|
+
* and take blanket elevation for a case where a perfectly good scoped identity
|
|
2858
|
+
* existed. Re-attaching the actor lets those cascades run as the deciding user
|
|
2859
|
+
* with RLS enforced. Same shape the approval node already uses when it calls
|
|
2860
|
+
* into this service (`approval-node.ts`).
|
|
2861
|
+
*
|
|
2862
|
+
* `actorId` is `null` for the genuinely machine-driven transitions (the SLA
|
|
2863
|
+
* escalation's auto-decision, the dead-run sweep). There is no human to name
|
|
2864
|
+
* there, and naming a sentinel would put a non-user in `updated_by` and in
|
|
2865
|
+
* every downstream flow's identity. Those cascades stay user-less — a flow
|
|
2866
|
+
* that wants to react to them still has to declare `runAs:'system'`, which is
|
|
2867
|
+
* the honest answer rather than an oversight.
|
|
2868
|
+
*
|
|
2869
|
+
* Deliberately carries `userId` ONLY, not the request's org. On an
|
|
2870
|
+
* ExecutionContext `tenantId` is a driver-scoping knob, not attribution
|
|
2871
|
+
* (`buildDriverOptions` turns it into a tenant predicate on the update), so
|
|
2872
|
+
* passing it would newly org-scope this write and silently no-op the mirror on
|
|
2873
|
+
* a record whose org differs from the request's — while buying nothing: the
|
|
2874
|
+
* automation engine back-fills the run's `tenantId` from the resolved user's
|
|
2875
|
+
* own grants.
|
|
2876
|
+
*/
|
|
2877
|
+
async mirrorStatusField(object, recordId, field, status, actorId) {
|
|
1924
2878
|
try {
|
|
1925
|
-
|
|
2879
|
+
const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
|
|
2880
|
+
await this.engine.update(object, { id: recordId, [field]: status }, { context });
|
|
1926
2881
|
} catch (err) {
|
|
1927
2882
|
this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
|
|
1928
2883
|
}
|
|
1929
2884
|
}
|
|
2885
|
+
/**
|
|
2886
|
+
* Re-read a business record's CURRENT state by id so approver resolution binds
|
|
2887
|
+
* to live data at node entry, not the trigger snapshot the flow froze into
|
|
2888
|
+
* `$record` at submit time (#3447). A `field` / `manager` approver names *who*
|
|
2889
|
+
* decides, and an earlier node — or the approver of an earlier step — may have
|
|
2890
|
+
* written that routing field after submit (e.g. a lead reviewer picking which
|
|
2891
|
+
* departments co-review). Graph approvers (team / position / …) already query
|
|
2892
|
+
* live; this brings the in-record types into line.
|
|
2893
|
+
*
|
|
2894
|
+
* Read under system identity: approver routing is a platform concern and the
|
|
2895
|
+
* record is the flow's own subject, so the submitter's RLS/FLS must not narrow
|
|
2896
|
+
* it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
|
|
2897
|
+
* hard-deleted between submit and node entry, or an object whose backend can't
|
|
2898
|
+
* serve a point read — warning rather than throwing so a transient miss can't
|
|
2899
|
+
* wedge an approval. That "warn but proceed" stance matches the
|
|
2900
|
+
* no-concrete-approver guard (#3424) and the "record is gone" enrichment path
|
|
2901
|
+
* that already falls back to the payload snapshot.
|
|
2902
|
+
*/
|
|
2903
|
+
async loadLiveRecord(object, recordId, fallback) {
|
|
2904
|
+
try {
|
|
2905
|
+
const rows = await this.engine.find(object, {
|
|
2906
|
+
where: { id: recordId },
|
|
2907
|
+
limit: 1,
|
|
2908
|
+
context: SYSTEM_CTX2
|
|
2909
|
+
});
|
|
2910
|
+
const live = Array.isArray(rows) ? rows[0] : rows;
|
|
2911
|
+
if (live) return live;
|
|
2912
|
+
this.logger?.warn?.(
|
|
2913
|
+
`[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
|
|
2914
|
+
{ object, recordId }
|
|
2915
|
+
);
|
|
2916
|
+
} catch (err) {
|
|
2917
|
+
this.logger?.warn?.(
|
|
2918
|
+
`[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
|
|
2919
|
+
);
|
|
2920
|
+
}
|
|
2921
|
+
return fallback ?? {};
|
|
2922
|
+
}
|
|
1930
2923
|
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
|
|
1931
2924
|
//
|
|
1932
2925
|
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
|
|
@@ -1938,6 +2931,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1938
2931
|
* Open a pending approval request on behalf of a flow's Approval node. The
|
|
1939
2932
|
* node config (approvers / behavior / status field) is snapshotted on the row
|
|
1940
2933
|
* so a decision can be made without any process to resolve against.
|
|
2934
|
+
*
|
|
2935
|
+
* #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
|
|
2936
|
+
* request opened — when the slate resolves empty and the node's
|
|
2937
|
+
* `onEmptyApprovers` policy is `auto_approve`.
|
|
1941
2938
|
*/
|
|
1942
2939
|
async openNodeRequest(input, context) {
|
|
1943
2940
|
if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
|
|
@@ -1946,7 +2943,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1946
2943
|
const existing = await this.engine.find("sys_approval_request", {
|
|
1947
2944
|
where: { object_name: input.object, record_id: input.recordId, status: "pending" },
|
|
1948
2945
|
limit: 1,
|
|
1949
|
-
context:
|
|
2946
|
+
context: SYSTEM_CTX2
|
|
1950
2947
|
});
|
|
1951
2948
|
if (Array.isArray(existing) && existing[0]) {
|
|
1952
2949
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
@@ -1955,26 +2952,56 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1955
2952
|
const nowDate = this.clock.now();
|
|
1956
2953
|
const substitutions = [];
|
|
1957
2954
|
const groups = {};
|
|
2955
|
+
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
2956
|
+
const resolvedFrom = {};
|
|
1958
2957
|
const approvers = await this.expandApprovers(
|
|
1959
2958
|
{ approvers: input.config.approvers },
|
|
1960
|
-
|
|
2959
|
+
liveRecord,
|
|
1961
2960
|
ctxOrg,
|
|
1962
|
-
{
|
|
2961
|
+
{
|
|
2962
|
+
now: nowDate.getTime(),
|
|
2963
|
+
substitutions,
|
|
2964
|
+
groups,
|
|
2965
|
+
exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
|
|
2966
|
+
resolvedFrom
|
|
2967
|
+
}
|
|
1963
2968
|
);
|
|
2969
|
+
if (!approvers.some((a) => a && !a.includes(":"))) {
|
|
2970
|
+
const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
|
|
2971
|
+
if (emptyPolicy === "fail") {
|
|
2972
|
+
throw new Error(
|
|
2973
|
+
`NO_APPROVERS: approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) are staffed / the routing field or expression yields user ids at node entry.`
|
|
2974
|
+
);
|
|
2975
|
+
}
|
|
2976
|
+
if (emptyPolicy === "auto_approve") {
|
|
2977
|
+
this.logger?.warn?.(
|
|
2978
|
+
`[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver \u2014 auto-approving per onEmptyApprovers: 'auto_approve' (no request opened).`,
|
|
2979
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
2980
|
+
);
|
|
2981
|
+
return { autoApproved: true, reason: "empty_approvers" };
|
|
2982
|
+
}
|
|
2983
|
+
this.logger?.warn?.(
|
|
2984
|
+
`[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver \u2014 the request is decidable only by a privileged admin. Check that the approver target(s) are staffed.`,
|
|
2985
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
2986
|
+
);
|
|
2987
|
+
}
|
|
1964
2988
|
const now = nowDate.toISOString();
|
|
1965
2989
|
const id = uid("areq");
|
|
1966
2990
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1967
2991
|
const configSnapshot = { ...input.config };
|
|
1968
2992
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1969
2993
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1970
|
-
if (input.config.behavior
|
|
2994
|
+
if (input.config.behavior && input.config.behavior !== "first_response") {
|
|
1971
2995
|
configSnapshot.__approverGroups = groups;
|
|
1972
2996
|
}
|
|
2997
|
+
if (Object.keys(resolvedFrom).length) {
|
|
2998
|
+
configSnapshot.__resolvedFrom = resolvedFrom;
|
|
2999
|
+
}
|
|
1973
3000
|
try {
|
|
1974
3001
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1975
3002
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
1976
3003
|
limit: 500,
|
|
1977
|
-
context:
|
|
3004
|
+
context: SYSTEM_CTX2
|
|
1978
3005
|
});
|
|
1979
3006
|
const n = Array.isArray(prior) ? prior.length : 0;
|
|
1980
3007
|
if (n > 0) configSnapshot.__round = n + 1;
|
|
@@ -1998,7 +3025,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1998
3025
|
created_at: now,
|
|
1999
3026
|
updated_at: now
|
|
2000
3027
|
};
|
|
2001
|
-
await this.engine.insert("sys_approval_request", row, { context:
|
|
3028
|
+
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
2002
3029
|
await this.syncApproverIndex(id, approvers, ctxOrg, now);
|
|
2003
3030
|
await this.engine.insert("sys_approval_action", {
|
|
2004
3031
|
id: uid("aact"),
|
|
@@ -2010,7 +3037,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2010
3037
|
actor_id: input.submitterId ?? context.userId ?? null,
|
|
2011
3038
|
comment: null,
|
|
2012
3039
|
created_at: now
|
|
2013
|
-
}, { context:
|
|
3040
|
+
}, { context: SYSTEM_CTX2 });
|
|
2014
3041
|
for (const sub of substitutions) {
|
|
2015
3042
|
await this.engine.insert("sys_approval_action", {
|
|
2016
3043
|
id: uid("aact"),
|
|
@@ -2022,7 +3049,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2022
3049
|
actor_id: null,
|
|
2023
3050
|
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2024
3051
|
created_at: now
|
|
2025
|
-
}, { context:
|
|
3052
|
+
}, { context: SYSTEM_CTX2 });
|
|
2026
3053
|
await this.notify({
|
|
2027
3054
|
topic: "approval.ooo_substituted",
|
|
2028
3055
|
audience: [sub.to],
|
|
@@ -2047,7 +3074,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2047
3074
|
});
|
|
2048
3075
|
}
|
|
2049
3076
|
if (input.config.approvalStatusField) {
|
|
2050
|
-
await this.mirrorStatusField(
|
|
3077
|
+
await this.mirrorStatusField(
|
|
3078
|
+
input.object,
|
|
3079
|
+
input.recordId,
|
|
3080
|
+
input.config.approvalStatusField,
|
|
3081
|
+
"pending",
|
|
3082
|
+
row.submitter_id ?? null
|
|
3083
|
+
);
|
|
2051
3084
|
}
|
|
2052
3085
|
return rowFromRequest(row);
|
|
2053
3086
|
}
|
|
@@ -2091,27 +3124,52 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2091
3124
|
*/
|
|
2092
3125
|
async decideNode(requestId, input, context) {
|
|
2093
3126
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2094
|
-
|
|
3127
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2095
3128
|
if (input.decision !== "approve" && input.decision !== "reject") {
|
|
2096
3129
|
throw new Error("VALIDATION_FAILED: decision must be approve|reject");
|
|
2097
3130
|
}
|
|
2098
3131
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2099
3132
|
where: { id: requestId },
|
|
2100
3133
|
limit: 1,
|
|
2101
|
-
context:
|
|
3134
|
+
context: SYSTEM_CTX2
|
|
2102
3135
|
});
|
|
2103
3136
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2104
3137
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2105
3138
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2106
3139
|
const pendingApprovers = csvSplit(raw.pending_approvers);
|
|
2107
|
-
|
|
2108
|
-
|
|
3140
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3141
|
+
const isSlotHolder = pendingApprovers.includes(actorId);
|
|
3142
|
+
if (!isSlotHolder && !isOverride) {
|
|
3143
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2109
3144
|
}
|
|
2110
3145
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2111
3146
|
const org = raw.organization_id ?? null;
|
|
2112
3147
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
2113
3148
|
const runId = raw.flow_run_id ?? null;
|
|
2114
3149
|
const now = this.clock.now().toISOString();
|
|
3150
|
+
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
3151
|
+
let acceptedOutputs;
|
|
3152
|
+
if (outputKeys.length) {
|
|
3153
|
+
const declared = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs).map((d) => d.key);
|
|
3154
|
+
if (!declared.length) {
|
|
3155
|
+
throw new Error(
|
|
3156
|
+
`VALIDATION_FAILED: this approval node declares no decisionOutputs \u2014 outputs are not accepted. Declare the keys on the node config (decisionOutputs: [${outputKeys.map((k) => `'${k}'`).join(", ")}]) to let approvers hand them to the flow.`
|
|
3157
|
+
);
|
|
3158
|
+
}
|
|
3159
|
+
const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
|
|
3160
|
+
if (reserved.length) {
|
|
3161
|
+
throw new Error(
|
|
3162
|
+
`VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
|
|
3163
|
+
);
|
|
3164
|
+
}
|
|
3165
|
+
const undeclared = outputKeys.filter((k) => !declared.includes(k));
|
|
3166
|
+
if (undeclared.length) {
|
|
3167
|
+
throw new Error(
|
|
3168
|
+
`VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
|
|
3169
|
+
);
|
|
3170
|
+
}
|
|
3171
|
+
acceptedOutputs = { ...input.outputs };
|
|
3172
|
+
}
|
|
2115
3173
|
await this.engine.insert("sys_approval_action", {
|
|
2116
3174
|
id: uid("aact"),
|
|
2117
3175
|
request_id: requestId,
|
|
@@ -2119,23 +3177,23 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2119
3177
|
step_name: nodeId,
|
|
2120
3178
|
step_index: 0,
|
|
2121
3179
|
action: input.decision,
|
|
2122
|
-
actor_id:
|
|
3180
|
+
actor_id: actorId,
|
|
2123
3181
|
comment: input.comment ?? null,
|
|
2124
3182
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2125
3183
|
created_at: now
|
|
2126
|
-
}, { context:
|
|
3184
|
+
}, { context: SYSTEM_CTX2 });
|
|
2127
3185
|
const behavior = config.behavior ?? "first_response";
|
|
2128
|
-
if (input.decision === "approve" && behavior !== "first_response") {
|
|
3186
|
+
if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
|
|
2129
3187
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2130
3188
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
2131
3189
|
limit: 1e3,
|
|
2132
|
-
context:
|
|
3190
|
+
context: SYSTEM_CTX2
|
|
2133
3191
|
});
|
|
2134
3192
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
2135
3193
|
const snapshotGroups = config.__approverGroups;
|
|
2136
3194
|
let original;
|
|
2137
3195
|
let groupMap;
|
|
2138
|
-
if (snapshotGroups
|
|
3196
|
+
if (snapshotGroups) {
|
|
2139
3197
|
groupMap = snapshotGroups;
|
|
2140
3198
|
original = Object.keys(snapshotGroups);
|
|
2141
3199
|
} else {
|
|
@@ -2151,27 +3209,65 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2151
3209
|
await this.engine.update("sys_approval_request", {
|
|
2152
3210
|
id: requestId,
|
|
2153
3211
|
pending_approvers: stillPending.join(","),
|
|
2154
|
-
updated_at: now
|
|
2155
|
-
|
|
3212
|
+
updated_at: now,
|
|
3213
|
+
// #3447 P2: a mid-tally approval may carry outputs too (unanimous /
|
|
3214
|
+
// per_group co-sign, each approver contributing their declared keys)
|
|
3215
|
+
// — accumulate them on the snapshot so the FINALIZING decision hands
|
|
3216
|
+
// the merged set to the flow.
|
|
3217
|
+
...acceptedOutputs ? {
|
|
3218
|
+
node_config_json: JSON.stringify({
|
|
3219
|
+
...config,
|
|
3220
|
+
__decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
|
|
3221
|
+
})
|
|
3222
|
+
} : {}
|
|
3223
|
+
}, { context: SYSTEM_CTX2 });
|
|
2156
3224
|
await this.syncApproverIndex(requestId, stillPending, org, now);
|
|
2157
|
-
const fresh2 = await this.
|
|
3225
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2158
3226
|
return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
|
|
2159
3227
|
}
|
|
2160
3228
|
}
|
|
2161
3229
|
const finalStatus = input.decision === "approve" ? "approved" : "rejected";
|
|
3230
|
+
const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
|
|
2162
3231
|
await this.engine.update("sys_approval_request", {
|
|
2163
3232
|
id: requestId,
|
|
2164
3233
|
status: finalStatus,
|
|
2165
3234
|
pending_approvers: null,
|
|
2166
3235
|
completed_at: now,
|
|
2167
|
-
updated_at: now
|
|
2168
|
-
|
|
3236
|
+
updated_at: now,
|
|
3237
|
+
...mergedOutputs ? {
|
|
3238
|
+
node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
|
|
3239
|
+
} : {}
|
|
3240
|
+
}, { context: SYSTEM_CTX2 });
|
|
2169
3241
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2170
3242
|
if (config.approvalStatusField) {
|
|
2171
|
-
await this.mirrorStatusField(
|
|
3243
|
+
await this.mirrorStatusField(
|
|
3244
|
+
raw.object_name,
|
|
3245
|
+
raw.record_id,
|
|
3246
|
+
config.approvalStatusField,
|
|
3247
|
+
finalStatus,
|
|
3248
|
+
actingUserId(context)
|
|
3249
|
+
);
|
|
2172
3250
|
}
|
|
2173
|
-
const fresh = await this.
|
|
2174
|
-
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
|
|
3251
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
3252
|
+
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
|
|
3253
|
+
}
|
|
3254
|
+
/**
|
|
3255
|
+
* Continue the owning flow run after an outcome this service has already
|
|
3256
|
+
* authorized and written down (#3801).
|
|
3257
|
+
*
|
|
3258
|
+
* The `approval` node declares `resumeAuthority: 'service'`, so the engine
|
|
3259
|
+
* refuses any resume of an approval suspension that does not carry
|
|
3260
|
+
* {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
|
|
3261
|
+
* here so the marker is stamped in ONE place — a new outcome path cannot
|
|
3262
|
+
* quietly ship a resume that the gate then rejects at runtime, and nothing
|
|
3263
|
+
* in this file hands the marker to a caller-supplied signal.
|
|
3264
|
+
*
|
|
3265
|
+
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3266
|
+
* (approvals runs fine with no automation attached) and keep their own
|
|
3267
|
+
* try/catch, because what a failed resume means differs per path.
|
|
3268
|
+
*/
|
|
3269
|
+
async serviceResume(runId, signal) {
|
|
3270
|
+
await this.automation.resume(runId, { ...signal, [import_contracts.RESUME_AUTHORITY_SERVICE]: true });
|
|
2175
3271
|
}
|
|
2176
3272
|
/**
|
|
2177
3273
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
@@ -2184,9 +3280,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2184
3280
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
2185
3281
|
const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
|
|
2186
3282
|
try {
|
|
2187
|
-
await this.
|
|
3283
|
+
await this.serviceResume(result.runId, {
|
|
2188
3284
|
branchLabel,
|
|
2189
|
-
|
|
3285
|
+
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3286
|
+
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
3287
|
+
// `expression` approver reads them as `vars.<nodeId>.<key>`.
|
|
3288
|
+
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3289
|
+
// whitelist already rejects them; this is defense in depth).
|
|
3290
|
+
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
2190
3291
|
});
|
|
2191
3292
|
resumed = true;
|
|
2192
3293
|
} catch (err) {
|
|
@@ -2220,11 +3321,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2220
3321
|
*/
|
|
2221
3322
|
async recall(requestId, input, context) {
|
|
2222
3323
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2223
|
-
|
|
3324
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2224
3325
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2225
3326
|
where: { id: requestId },
|
|
2226
3327
|
limit: 1,
|
|
2227
|
-
context:
|
|
3328
|
+
context: SYSTEM_CTX2
|
|
2228
3329
|
});
|
|
2229
3330
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2230
3331
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
@@ -2232,7 +3333,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2232
3333
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
2233
3334
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2234
3335
|
}
|
|
2235
|
-
if (!context.
|
|
3336
|
+
if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2236
3337
|
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
2237
3338
|
}
|
|
2238
3339
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
@@ -2248,20 +3349,26 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2248
3349
|
step_name: nodeId,
|
|
2249
3350
|
step_index: 0,
|
|
2250
3351
|
action: "recall",
|
|
2251
|
-
actor_id:
|
|
3352
|
+
actor_id: actorId,
|
|
2252
3353
|
comment: input.comment ?? null,
|
|
2253
3354
|
created_at: now
|
|
2254
|
-
}, { context:
|
|
3355
|
+
}, { context: SYSTEM_CTX2 });
|
|
2255
3356
|
await this.engine.update("sys_approval_request", {
|
|
2256
3357
|
id: requestId,
|
|
2257
3358
|
status: "recalled",
|
|
2258
3359
|
pending_approvers: null,
|
|
2259
3360
|
completed_at: now,
|
|
2260
3361
|
updated_at: now
|
|
2261
|
-
}, { context:
|
|
3362
|
+
}, { context: SYSTEM_CTX2 });
|
|
2262
3363
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2263
3364
|
if (config.approvalStatusField) {
|
|
2264
|
-
await this.mirrorStatusField(
|
|
3365
|
+
await this.mirrorStatusField(
|
|
3366
|
+
raw.object_name,
|
|
3367
|
+
raw.record_id,
|
|
3368
|
+
config.approvalStatusField,
|
|
3369
|
+
"recalled",
|
|
3370
|
+
actingUserId(context)
|
|
3371
|
+
);
|
|
2265
3372
|
}
|
|
2266
3373
|
let resumed = false;
|
|
2267
3374
|
if (inReviseWindow) {
|
|
@@ -2278,7 +3385,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2278
3385
|
}
|
|
2279
3386
|
} else if (runId && typeof this.automation?.resume === "function") {
|
|
2280
3387
|
try {
|
|
2281
|
-
await this.
|
|
3388
|
+
await this.serviceResume(runId, {
|
|
2282
3389
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
|
|
2283
3390
|
output: { decision: "recall", requestId }
|
|
2284
3391
|
});
|
|
@@ -2291,7 +3398,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2291
3398
|
});
|
|
2292
3399
|
}
|
|
2293
3400
|
}
|
|
2294
|
-
const fresh = await this.
|
|
3401
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2295
3402
|
return { request: fresh, runId, resumed };
|
|
2296
3403
|
}
|
|
2297
3404
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
@@ -2309,11 +3416,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2309
3416
|
* `output.autoRejected = true`) so instances cannot orbit forever.
|
|
2310
3417
|
*/
|
|
2311
3418
|
async sendBack(requestId, input, context) {
|
|
2312
|
-
|
|
3419
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2313
3420
|
const raw = await this.loadPendingRow(requestId);
|
|
2314
3421
|
const pending = csvSplit(raw.pending_approvers);
|
|
2315
|
-
if (!context.isSystem && !pending.includes(
|
|
2316
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3422
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3423
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2317
3424
|
}
|
|
2318
3425
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2319
3426
|
const org = raw.organization_id ?? null;
|
|
@@ -2327,7 +3434,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2327
3434
|
const siblings = await this.engine.find("sys_approval_request", {
|
|
2328
3435
|
where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
|
|
2329
3436
|
limit: 500,
|
|
2330
|
-
context:
|
|
3437
|
+
context: SYSTEM_CTX2
|
|
2331
3438
|
});
|
|
2332
3439
|
priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
|
|
2333
3440
|
}
|
|
@@ -2338,10 +3445,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2338
3445
|
step_name: nodeId,
|
|
2339
3446
|
step_index: 0,
|
|
2340
3447
|
action: "revise",
|
|
2341
|
-
actor_id:
|
|
3448
|
+
actor_id: actorId,
|
|
2342
3449
|
comment: input.comment ?? null,
|
|
2343
3450
|
created_at: now
|
|
2344
|
-
}, { context:
|
|
3451
|
+
}, { context: SYSTEM_CTX2 });
|
|
2345
3452
|
if (priorSendBacks >= maxRevisions) {
|
|
2346
3453
|
await this.engine.insert("sys_approval_action", {
|
|
2347
3454
|
id: uid("aact"),
|
|
@@ -2350,25 +3457,31 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2350
3457
|
step_name: nodeId,
|
|
2351
3458
|
step_index: 0,
|
|
2352
3459
|
action: "reject",
|
|
2353
|
-
actor_id:
|
|
3460
|
+
actor_id: actorId,
|
|
2354
3461
|
comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
|
|
2355
3462
|
created_at: now
|
|
2356
|
-
}, { context:
|
|
3463
|
+
}, { context: SYSTEM_CTX2 });
|
|
2357
3464
|
await this.engine.update("sys_approval_request", {
|
|
2358
3465
|
id: requestId,
|
|
2359
3466
|
status: "rejected",
|
|
2360
3467
|
pending_approvers: null,
|
|
2361
3468
|
completed_at: now,
|
|
2362
3469
|
updated_at: now
|
|
2363
|
-
}, { context:
|
|
3470
|
+
}, { context: SYSTEM_CTX2 });
|
|
2364
3471
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2365
3472
|
if (config.approvalStatusField) {
|
|
2366
|
-
await this.mirrorStatusField(
|
|
3473
|
+
await this.mirrorStatusField(
|
|
3474
|
+
raw.object_name,
|
|
3475
|
+
raw.record_id,
|
|
3476
|
+
config.approvalStatusField,
|
|
3477
|
+
"rejected",
|
|
3478
|
+
actingUserId(context)
|
|
3479
|
+
);
|
|
2367
3480
|
}
|
|
2368
3481
|
let resumed2 = false;
|
|
2369
3482
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2370
3483
|
try {
|
|
2371
|
-
await this.
|
|
3484
|
+
await this.serviceResume(runId, {
|
|
2372
3485
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
|
|
2373
3486
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
2374
3487
|
});
|
|
@@ -2385,7 +3498,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2385
3498
|
await this.notify({
|
|
2386
3499
|
topic: "approval.returned",
|
|
2387
3500
|
audience: [String(raw.submitter_id)],
|
|
2388
|
-
actorId
|
|
3501
|
+
actorId,
|
|
2389
3502
|
source: { object: "sys_approval_request", id: requestId },
|
|
2390
3503
|
payload: {
|
|
2391
3504
|
title: "Approval auto-rejected",
|
|
@@ -2394,7 +3507,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2394
3507
|
}
|
|
2395
3508
|
});
|
|
2396
3509
|
}
|
|
2397
|
-
const fresh2 = await this.
|
|
3510
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2398
3511
|
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
2399
3512
|
}
|
|
2400
3513
|
await this.engine.update("sys_approval_request", {
|
|
@@ -2403,15 +3516,21 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2403
3516
|
pending_approvers: null,
|
|
2404
3517
|
completed_at: now,
|
|
2405
3518
|
updated_at: now
|
|
2406
|
-
}, { context:
|
|
3519
|
+
}, { context: SYSTEM_CTX2 });
|
|
2407
3520
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2408
3521
|
if (config.approvalStatusField) {
|
|
2409
|
-
await this.mirrorStatusField(
|
|
3522
|
+
await this.mirrorStatusField(
|
|
3523
|
+
raw.object_name,
|
|
3524
|
+
raw.record_id,
|
|
3525
|
+
config.approvalStatusField,
|
|
3526
|
+
"returned",
|
|
3527
|
+
actingUserId(context)
|
|
3528
|
+
);
|
|
2410
3529
|
}
|
|
2411
3530
|
let resumed = false;
|
|
2412
3531
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2413
3532
|
try {
|
|
2414
|
-
await this.
|
|
3533
|
+
await this.serviceResume(runId, {
|
|
2415
3534
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
|
|
2416
3535
|
output: { decision: "revise", requestId }
|
|
2417
3536
|
});
|
|
@@ -2428,7 +3547,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2428
3547
|
await this.notify({
|
|
2429
3548
|
topic: "approval.returned",
|
|
2430
3549
|
audience: [String(raw.submitter_id)],
|
|
2431
|
-
actorId
|
|
3550
|
+
actorId,
|
|
2432
3551
|
source: { object: "sys_approval_request", id: requestId },
|
|
2433
3552
|
payload: {
|
|
2434
3553
|
title: "Sent back for revision",
|
|
@@ -2437,7 +3556,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2437
3556
|
}
|
|
2438
3557
|
});
|
|
2439
3558
|
}
|
|
2440
|
-
const fresh = await this.
|
|
3559
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2441
3560
|
return { request: fresh, runId, resumed };
|
|
2442
3561
|
}
|
|
2443
3562
|
/**
|
|
@@ -2448,25 +3567,25 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2448
3567
|
* round-N+1 request — fresh approver slate, record re-locks.
|
|
2449
3568
|
*/
|
|
2450
3569
|
async resubmit(requestId, input, context) {
|
|
2451
|
-
|
|
3570
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2452
3571
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2453
3572
|
where: { id: requestId },
|
|
2454
3573
|
limit: 1,
|
|
2455
|
-
context:
|
|
3574
|
+
context: SYSTEM_CTX2
|
|
2456
3575
|
});
|
|
2457
3576
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2458
3577
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2459
3578
|
if (raw.status !== "returned") {
|
|
2460
3579
|
throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
|
|
2461
3580
|
}
|
|
2462
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3581
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2463
3582
|
throw new Error("FORBIDDEN: only the submitter may resubmit");
|
|
2464
3583
|
}
|
|
2465
3584
|
await this.assertLatestForRun(raw);
|
|
2466
3585
|
const colliding = await this.engine.find("sys_approval_request", {
|
|
2467
3586
|
where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
|
|
2468
3587
|
limit: 1,
|
|
2469
|
-
context:
|
|
3588
|
+
context: SYSTEM_CTX2
|
|
2470
3589
|
});
|
|
2471
3590
|
if (Array.isArray(colliding) && colliding[0]) {
|
|
2472
3591
|
throw new Error(
|
|
@@ -2484,14 +3603,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2484
3603
|
step_name: nodeId,
|
|
2485
3604
|
step_index: 0,
|
|
2486
3605
|
action: "resubmit",
|
|
2487
|
-
actor_id:
|
|
3606
|
+
actor_id: actorId,
|
|
2488
3607
|
comment: input.comment ?? null,
|
|
2489
3608
|
created_at: now
|
|
2490
|
-
}, { context:
|
|
3609
|
+
}, { context: SYSTEM_CTX2 });
|
|
2491
3610
|
let resumed = false;
|
|
2492
3611
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2493
3612
|
try {
|
|
2494
|
-
await this.
|
|
3613
|
+
await this.serviceResume(runId, {
|
|
2495
3614
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
|
|
2496
3615
|
output: { resubmitted: true, requestId }
|
|
2497
3616
|
});
|
|
@@ -2504,7 +3623,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2504
3623
|
});
|
|
2505
3624
|
}
|
|
2506
3625
|
}
|
|
2507
|
-
const fresh = await this.
|
|
3626
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2508
3627
|
return { request: fresh, runId, resumed };
|
|
2509
3628
|
}
|
|
2510
3629
|
/**
|
|
@@ -2539,7 +3658,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2539
3658
|
where: { flow_run_id: runId },
|
|
2540
3659
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2541
3660
|
limit: 1,
|
|
2542
|
-
context:
|
|
3661
|
+
context: SYSTEM_CTX2
|
|
2543
3662
|
});
|
|
2544
3663
|
const latest = Array.isArray(rows) ? rows[0] : null;
|
|
2545
3664
|
if (latest && String(latest.id) !== String(raw.id)) {
|
|
@@ -2550,25 +3669,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2550
3669
|
/**
|
|
2551
3670
|
* Hand a pending-approver slot to someone else. `from` defaults to the
|
|
2552
3671
|
* actor itself; the actor must hold the slot being handed over (or be a
|
|
2553
|
-
* system caller).
|
|
3672
|
+
* system caller). A privileged admin (#3424) may reassign a request whose
|
|
3673
|
+
* slate holds no real user — an unstaffed-position literal — by handing the
|
|
3674
|
+
* whole request to a real approver, rescuing it from the locked dead-end.
|
|
3675
|
+
* Audits `reassign` and notifies the new approver.
|
|
2554
3676
|
*/
|
|
2555
3677
|
async reassign(requestId, input, context) {
|
|
2556
|
-
|
|
3678
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2557
3679
|
const to = String(input?.to ?? "").trim();
|
|
2558
3680
|
if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
|
|
2559
3681
|
const raw = await this.loadPendingRow(requestId);
|
|
2560
3682
|
const pending = csvSplit(raw.pending_approvers);
|
|
2561
|
-
const from = String(input.from ?? input.actorId).trim();
|
|
2562
|
-
if (!pending.includes(from)) {
|
|
2563
|
-
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
2564
|
-
}
|
|
2565
|
-
if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
|
|
2566
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
2567
|
-
}
|
|
2568
3683
|
if (pending.includes(to)) {
|
|
2569
3684
|
throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
|
|
2570
3685
|
}
|
|
2571
|
-
const
|
|
3686
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3687
|
+
const from = String(input.from ?? actorId).trim();
|
|
3688
|
+
let next;
|
|
3689
|
+
if (pending.includes(from)) {
|
|
3690
|
+
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
3691
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3692
|
+
}
|
|
3693
|
+
next = pending.map((a) => a === from ? to : a);
|
|
3694
|
+
} else if (isOverride) {
|
|
3695
|
+
next = [to];
|
|
3696
|
+
} else {
|
|
3697
|
+
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
3698
|
+
}
|
|
2572
3699
|
const now = this.clock.now().toISOString();
|
|
2573
3700
|
await this.engine.insert("sys_approval_action", {
|
|
2574
3701
|
id: uid("aact"),
|
|
@@ -2577,10 +3704,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2577
3704
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2578
3705
|
step_index: 0,
|
|
2579
3706
|
action: "reassign",
|
|
2580
|
-
actor_id:
|
|
3707
|
+
actor_id: actorId,
|
|
2581
3708
|
comment: input.comment ?? `${from} \u2192 ${to}`,
|
|
2582
3709
|
created_at: now
|
|
2583
|
-
}, { context:
|
|
3710
|
+
}, { context: SYSTEM_CTX2 });
|
|
2584
3711
|
let configPatch = {};
|
|
2585
3712
|
try {
|
|
2586
3713
|
const cfg = parseJson(raw.node_config_json, null);
|
|
@@ -2597,12 +3724,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2597
3724
|
pending_approvers: next.join(","),
|
|
2598
3725
|
updated_at: now,
|
|
2599
3726
|
...configPatch
|
|
2600
|
-
}, { context:
|
|
3727
|
+
}, { context: SYSTEM_CTX2 });
|
|
2601
3728
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
2602
3729
|
await this.notify({
|
|
2603
3730
|
topic: "approval.reassigned",
|
|
2604
3731
|
audience: [to],
|
|
2605
|
-
actorId
|
|
3732
|
+
actorId,
|
|
2606
3733
|
source: { object: "sys_approval_request", id: requestId },
|
|
2607
3734
|
dedupKey: `approval-reassign-${requestId}-${to}`,
|
|
2608
3735
|
payload: {
|
|
@@ -2611,7 +3738,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2611
3738
|
actionUrl: "/system/approvals"
|
|
2612
3739
|
}
|
|
2613
3740
|
});
|
|
2614
|
-
const fresh = await this.
|
|
3741
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2615
3742
|
return { request: fresh };
|
|
2616
3743
|
}
|
|
2617
3744
|
/**
|
|
@@ -2619,16 +3746,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2619
3746
|
* reminder per {@link REMIND_COOLDOWN_MS} per request.
|
|
2620
3747
|
*/
|
|
2621
3748
|
async remind(requestId, input, context) {
|
|
2622
|
-
|
|
3749
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2623
3750
|
const raw = await this.loadPendingRow(requestId);
|
|
2624
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3751
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2625
3752
|
throw new Error("FORBIDDEN: only the submitter may send reminders");
|
|
2626
3753
|
}
|
|
2627
3754
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2628
3755
|
where: { request_id: requestId, action: "remind" },
|
|
2629
3756
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2630
3757
|
limit: 1,
|
|
2631
|
-
context:
|
|
3758
|
+
context: SYSTEM_CTX2
|
|
2632
3759
|
});
|
|
2633
3760
|
const last = Array.isArray(acts) ? acts[0] : null;
|
|
2634
3761
|
const now = this.clock.now();
|
|
@@ -2644,10 +3771,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2644
3771
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2645
3772
|
step_index: 0,
|
|
2646
3773
|
action: "remind",
|
|
2647
|
-
actor_id:
|
|
3774
|
+
actor_id: actorId,
|
|
2648
3775
|
comment: input.comment ?? null,
|
|
2649
3776
|
created_at: nowIso
|
|
2650
|
-
}, { context:
|
|
3777
|
+
}, { context: SYSTEM_CTX2 });
|
|
2651
3778
|
let notified = 0;
|
|
2652
3779
|
const concrete = pending.filter((a) => a && !a.includes(":"));
|
|
2653
3780
|
const literals = pending.filter((a) => a && a.includes(":"));
|
|
@@ -2657,7 +3784,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2657
3784
|
notified += await this.notify({
|
|
2658
3785
|
topic: "approval.reminder",
|
|
2659
3786
|
audience: [approver],
|
|
2660
|
-
actorId
|
|
3787
|
+
actorId,
|
|
2661
3788
|
source: { object: "sys_approval_request", id: requestId },
|
|
2662
3789
|
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
|
|
2663
3790
|
payload: {
|
|
@@ -2682,7 +3809,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2682
3809
|
notified += await this.notify({
|
|
2683
3810
|
topic: "approval.reminder",
|
|
2684
3811
|
audience: literals,
|
|
2685
|
-
actorId
|
|
3812
|
+
actorId,
|
|
2686
3813
|
source: { object: "sys_approval_request", id: requestId },
|
|
2687
3814
|
dedupKey: `approval-remind-${requestId}-${nowIso}`,
|
|
2688
3815
|
payload: {
|
|
@@ -2692,7 +3819,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2692
3819
|
}
|
|
2693
3820
|
});
|
|
2694
3821
|
}
|
|
2695
|
-
const fresh = await this.
|
|
3822
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2696
3823
|
return { request: fresh, notified };
|
|
2697
3824
|
}
|
|
2698
3825
|
// ── Actionable links (ADR-0043) ──────────────────────────────
|
|
@@ -2727,7 +3854,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2727
3854
|
expires_at: expires,
|
|
2728
3855
|
consumed_at: null,
|
|
2729
3856
|
created_at: now.toISOString()
|
|
2730
|
-
}, { context:
|
|
3857
|
+
}, { context: SYSTEM_CTX2 });
|
|
2731
3858
|
out[action] = rawToken;
|
|
2732
3859
|
}
|
|
2733
3860
|
return out;
|
|
@@ -2740,7 +3867,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2740
3867
|
const rows = await this.engine.find("sys_approval_token", {
|
|
2741
3868
|
where: { token_hash: hash },
|
|
2742
3869
|
limit: 1,
|
|
2743
|
-
context:
|
|
3870
|
+
context: SYSTEM_CTX2
|
|
2744
3871
|
});
|
|
2745
3872
|
const token = Array.isArray(rows) ? rows[0] : null;
|
|
2746
3873
|
if (!token) return { ok: false, reason: "invalid" };
|
|
@@ -2748,7 +3875,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2748
3875
|
if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
|
|
2749
3876
|
return { ok: false, reason: "expired" };
|
|
2750
3877
|
}
|
|
2751
|
-
const request = await this.getRequest(token.request_id,
|
|
3878
|
+
const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
|
|
2752
3879
|
if (!request || request.status !== "pending") {
|
|
2753
3880
|
return { ok: false, reason: "not_pending", request: request ?? void 0 };
|
|
2754
3881
|
}
|
|
@@ -2773,12 +3900,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2773
3900
|
await this.engine.update("sys_approval_token", {
|
|
2774
3901
|
id: res.token.id,
|
|
2775
3902
|
consumed_at: this.clock.now().toISOString()
|
|
2776
|
-
}, { context:
|
|
3903
|
+
}, { context: SYSTEM_CTX2 });
|
|
2777
3904
|
const out = await this.decide(res.token.request_id, {
|
|
2778
3905
|
decision: res.token.action,
|
|
2779
3906
|
actorId: res.token.approver_id,
|
|
2780
3907
|
comment: "Via action link"
|
|
2781
|
-
|
|
3908
|
+
// The token IS the authentication (#3783): it is single-use, hashed at
|
|
3909
|
+
// rest and bound to one approver, who `resolveActionToken` has just
|
|
3910
|
+
// re-checked still holds a pending slot. So this decision has a real
|
|
3911
|
+
// acting user even though no session carried it — name them on the
|
|
3912
|
+
// context, so the status mirror and every flow it cascades into are
|
|
3913
|
+
// attributed exactly like a decision made through the UI. Elevation is
|
|
3914
|
+
// unchanged: `isSystem` still stands in for the missing session.
|
|
3915
|
+
}, { ...SYSTEM_CTX2, userId: res.token.approver_id });
|
|
2782
3916
|
return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
|
|
2783
3917
|
}
|
|
2784
3918
|
/**
|
|
@@ -2786,12 +3920,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2786
3920
|
* pending — a thread interaction, not a flow decision.
|
|
2787
3921
|
*/
|
|
2788
3922
|
async requestInfo(requestId, input, context) {
|
|
2789
|
-
|
|
3923
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2790
3924
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2791
3925
|
const raw = await this.loadPendingRow(requestId);
|
|
2792
3926
|
const pending = csvSplit(raw.pending_approvers);
|
|
2793
|
-
if (!context.isSystem && !pending.includes(
|
|
2794
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3927
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3928
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2795
3929
|
}
|
|
2796
3930
|
const now = this.clock.now().toISOString();
|
|
2797
3931
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2801,15 +3935,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2801
3935
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2802
3936
|
step_index: 0,
|
|
2803
3937
|
action: "request_info",
|
|
2804
|
-
actor_id:
|
|
3938
|
+
actor_id: actorId,
|
|
2805
3939
|
comment: input.comment.trim(),
|
|
2806
3940
|
created_at: now
|
|
2807
|
-
}, { context:
|
|
3941
|
+
}, { context: SYSTEM_CTX2 });
|
|
2808
3942
|
if (raw.submitter_id) {
|
|
2809
3943
|
await this.notify({
|
|
2810
3944
|
topic: "approval.request_info",
|
|
2811
3945
|
audience: [String(raw.submitter_id)],
|
|
2812
|
-
actorId
|
|
3946
|
+
actorId,
|
|
2813
3947
|
source: { object: "sys_approval_request", id: requestId },
|
|
2814
3948
|
payload: {
|
|
2815
3949
|
title: "More information requested",
|
|
@@ -2818,18 +3952,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2818
3952
|
}
|
|
2819
3953
|
});
|
|
2820
3954
|
}
|
|
2821
|
-
const fresh = await this.
|
|
3955
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2822
3956
|
return { request: fresh };
|
|
2823
3957
|
}
|
|
2824
3958
|
/** Free-form reply on the thread (submitter or any pending approver). */
|
|
2825
3959
|
async comment(requestId, input, context) {
|
|
2826
|
-
|
|
3960
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2827
3961
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2828
3962
|
const raw = await this.loadPendingRow(requestId);
|
|
2829
3963
|
const pending = csvSplit(raw.pending_approvers);
|
|
2830
|
-
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(
|
|
2831
|
-
if (!context.isSystem && !isSubmitter && !pending.includes(
|
|
2832
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3964
|
+
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
|
|
3965
|
+
if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
|
|
3966
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
|
|
2833
3967
|
}
|
|
2834
3968
|
const now = this.clock.now().toISOString();
|
|
2835
3969
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2839,16 +3973,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2839
3973
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2840
3974
|
step_index: 0,
|
|
2841
3975
|
action: "comment",
|
|
2842
|
-
actor_id:
|
|
3976
|
+
actor_id: actorId,
|
|
2843
3977
|
comment: input.comment.trim(),
|
|
2844
3978
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2845
3979
|
created_at: now
|
|
2846
|
-
}, { context:
|
|
3980
|
+
}, { context: SYSTEM_CTX2 });
|
|
2847
3981
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
2848
3982
|
await this.notify({
|
|
2849
3983
|
topic: "approval.comment",
|
|
2850
3984
|
audience,
|
|
2851
|
-
actorId
|
|
3985
|
+
actorId,
|
|
2852
3986
|
source: { object: "sys_approval_request", id: requestId },
|
|
2853
3987
|
payload: {
|
|
2854
3988
|
title: "New comment on an approval",
|
|
@@ -2856,7 +3990,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2856
3990
|
actionUrl: "/system/approvals"
|
|
2857
3991
|
}
|
|
2858
3992
|
});
|
|
2859
|
-
const fresh = await this.
|
|
3993
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2860
3994
|
return { request: fresh };
|
|
2861
3995
|
}
|
|
2862
3996
|
// ── SLA escalation (ADR-0042) ─────────────────────────────────
|
|
@@ -2873,7 +4007,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2873
4007
|
rows = await this.engine.find("sys_approval_request", {
|
|
2874
4008
|
where: { status: "pending" },
|
|
2875
4009
|
limit: 500,
|
|
2876
|
-
context:
|
|
4010
|
+
context: SYSTEM_CTX2
|
|
2877
4011
|
}) ?? [];
|
|
2878
4012
|
} catch (err) {
|
|
2879
4013
|
this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
|
|
@@ -2892,7 +4026,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2892
4026
|
const prior = await this.engine.find("sys_approval_action", {
|
|
2893
4027
|
where: { request_id: raw.id, action: "escalate" },
|
|
2894
4028
|
limit: 1,
|
|
2895
|
-
context:
|
|
4029
|
+
context: SYSTEM_CTX2
|
|
2896
4030
|
});
|
|
2897
4031
|
if (Array.isArray(prior) && prior[0]) continue;
|
|
2898
4032
|
await this.escalateRequest(raw, esc2);
|
|
@@ -2909,6 +4043,130 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2909
4043
|
}
|
|
2910
4044
|
return { scanned: rows.length, escalated };
|
|
2911
4045
|
}
|
|
4046
|
+
// ── Dead-run release (#3456) ──────────────────────────────────
|
|
4047
|
+
/**
|
|
4048
|
+
* One dead-run sweep: a pending request whose owning flow run has reached a
|
|
4049
|
+
* TERMINAL state can never be decided — nothing is left to resume — so the
|
|
4050
|
+
* request is finalised as `recalled` and, with `lockRecord`, the record it was
|
|
4051
|
+
* holding is released.
|
|
4052
|
+
*
|
|
4053
|
+
* This is the recovery half of #3456. The prevention half is the record lock's
|
|
4054
|
+
* owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
|
|
4055
|
+
* itself on its own lock in the first place; this sweep cleans up the runs that
|
|
4056
|
+
* still die — for any reason, including a process crash, which no in-band
|
|
4057
|
+
* handler can catch because the process that would have run it is gone.
|
|
4058
|
+
*
|
|
4059
|
+
* **Fail-safe by construction.** It acts only on an explicit terminal status
|
|
4060
|
+
* from a closed set. Every other answer — `paused` (the normal state of a run
|
|
4061
|
+
* waiting on its approval), `running`, an unrecognised status, `null` (unknown
|
|
4062
|
+
* run, evicted log, no durable store), a `getRun` that throws, or no automation
|
|
4063
|
+
* engine at all — is read as "still alive" and left strictly alone. The failure
|
|
4064
|
+
* mode is therefore "a dead run's lock survives until an admin recalls it"
|
|
4065
|
+
* (today's behaviour, #3424), never "a live approval is destroyed".
|
|
4066
|
+
*
|
|
4067
|
+
* `recalled` is the finalisation because it is the platform's existing terminal
|
|
4068
|
+
* state for *a live request that ended without a decision*; the audit row names
|
|
4069
|
+
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4070
|
+
* release is never mistaken for a submitter's withdrawal.
|
|
4071
|
+
*/
|
|
4072
|
+
async releaseDeadRunRequests() {
|
|
4073
|
+
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4074
|
+
let rows = [];
|
|
4075
|
+
try {
|
|
4076
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4077
|
+
where: { status: "pending" },
|
|
4078
|
+
limit: 500,
|
|
4079
|
+
context: SYSTEM_CTX2
|
|
4080
|
+
}) ?? [];
|
|
4081
|
+
} catch (err) {
|
|
4082
|
+
this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
|
|
4083
|
+
error: err?.message ?? String(err)
|
|
4084
|
+
});
|
|
4085
|
+
return { scanned: 0, released: 0 };
|
|
4086
|
+
}
|
|
4087
|
+
let released = 0;
|
|
4088
|
+
for (const raw of rows) {
|
|
4089
|
+
try {
|
|
4090
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4091
|
+
if (!runId) continue;
|
|
4092
|
+
let status;
|
|
4093
|
+
try {
|
|
4094
|
+
const run = await this.automation.getRun(runId);
|
|
4095
|
+
status = typeof run?.status === "string" ? run.status : void 0;
|
|
4096
|
+
} catch (err) {
|
|
4097
|
+
this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
|
|
4098
|
+
request: raw?.id,
|
|
4099
|
+
run: runId,
|
|
4100
|
+
error: err?.message ?? String(err)
|
|
4101
|
+
});
|
|
4102
|
+
continue;
|
|
4103
|
+
}
|
|
4104
|
+
if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
|
|
4105
|
+
await this.abandonForDeadRun(raw, runId, status);
|
|
4106
|
+
released++;
|
|
4107
|
+
} catch (err) {
|
|
4108
|
+
this.logger?.warn?.("[approvals] dead-run release failed for request", {
|
|
4109
|
+
request: raw?.id,
|
|
4110
|
+
error: err?.message ?? String(err)
|
|
4111
|
+
});
|
|
4112
|
+
}
|
|
4113
|
+
}
|
|
4114
|
+
if (released > 0) {
|
|
4115
|
+
this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
|
|
4116
|
+
}
|
|
4117
|
+
return { scanned: rows.length, released };
|
|
4118
|
+
}
|
|
4119
|
+
/**
|
|
4120
|
+
* Finalise one pending request whose owning run is terminal. Mirrors the
|
|
4121
|
+
* shape of {@link recall} — audit row first (so a crash mid-release leaves a
|
|
4122
|
+
* trace of the intent), then the status transition, approver-index sync and
|
|
4123
|
+
* the optional status-field mirror. No resume/cancel of the run: it is already
|
|
4124
|
+
* terminal, which is precisely why we are here.
|
|
4125
|
+
*/
|
|
4126
|
+
async abandonForDeadRun(raw, runId, runStatus) {
|
|
4127
|
+
const org = raw.organization_id ?? null;
|
|
4128
|
+
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
4129
|
+
const now = this.clock.now().toISOString();
|
|
4130
|
+
await this.engine.insert("sys_approval_action", {
|
|
4131
|
+
id: uid("aact"),
|
|
4132
|
+
request_id: raw.id,
|
|
4133
|
+
organization_id: org,
|
|
4134
|
+
step_name: nodeId,
|
|
4135
|
+
step_index: 0,
|
|
4136
|
+
action: "recall",
|
|
4137
|
+
actor_id: DEAD_RUN_ACTOR_ID,
|
|
4138
|
+
comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
|
|
4139
|
+
created_at: now
|
|
4140
|
+
}, { context: SYSTEM_CTX2 });
|
|
4141
|
+
await this.engine.update("sys_approval_request", {
|
|
4142
|
+
id: raw.id,
|
|
4143
|
+
status: "recalled",
|
|
4144
|
+
pending_approvers: null,
|
|
4145
|
+
completed_at: now,
|
|
4146
|
+
updated_at: now
|
|
4147
|
+
}, { context: SYSTEM_CTX2 });
|
|
4148
|
+
await this.syncApproverIndex(raw.id, [], org, now);
|
|
4149
|
+
const config = parseJson(
|
|
4150
|
+
raw.node_config_json,
|
|
4151
|
+
{ approvers: [], behavior: "first_response" }
|
|
4152
|
+
);
|
|
4153
|
+
if (config.approvalStatusField) {
|
|
4154
|
+
await this.mirrorStatusField(
|
|
4155
|
+
raw.object_name,
|
|
4156
|
+
raw.record_id,
|
|
4157
|
+
config.approvalStatusField,
|
|
4158
|
+
"recalled",
|
|
4159
|
+
null
|
|
4160
|
+
);
|
|
4161
|
+
}
|
|
4162
|
+
this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
|
|
4163
|
+
request: raw.id,
|
|
4164
|
+
run: runId,
|
|
4165
|
+
runStatus,
|
|
4166
|
+
object: raw.object_name,
|
|
4167
|
+
record: raw.record_id
|
|
4168
|
+
});
|
|
4169
|
+
}
|
|
2912
4170
|
/** Execute the configured escalation action for one overdue request. */
|
|
2913
4171
|
async escalateRequest(raw, esc2) {
|
|
2914
4172
|
const action = esc2.action ?? "notify";
|
|
@@ -2934,13 +4192,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2934
4192
|
actor_id: SLA_ACTOR_ID,
|
|
2935
4193
|
comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
|
|
2936
4194
|
created_at: now
|
|
2937
|
-
}, { context:
|
|
4195
|
+
}, { context: SYSTEM_CTX2 });
|
|
2938
4196
|
if (action === "reassign" && escalatees.length) {
|
|
2939
4197
|
await this.engine.update("sys_approval_request", {
|
|
2940
4198
|
id: raw.id,
|
|
2941
4199
|
pending_approvers: escalatees.join(","),
|
|
2942
4200
|
updated_at: now
|
|
2943
|
-
}, { context:
|
|
4201
|
+
}, { context: SYSTEM_CTX2 });
|
|
2944
4202
|
await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
|
|
2945
4203
|
await this.notify({
|
|
2946
4204
|
topic: "approval.escalated",
|
|
@@ -2958,7 +4216,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2958
4216
|
decision: action === "auto_approve" ? "approve" : "reject",
|
|
2959
4217
|
actorId: SLA_ACTOR_ID,
|
|
2960
4218
|
comment: "SLA escalation"
|
|
2961
|
-
},
|
|
4219
|
+
}, SYSTEM_CTX2);
|
|
2962
4220
|
} else {
|
|
2963
4221
|
await this.notify({
|
|
2964
4222
|
topic: "approval.sla_breached",
|
|
@@ -3026,7 +4284,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3026
4284
|
where: { id: { $in: targets } },
|
|
3027
4285
|
fields: ["id", "name", "email"],
|
|
3028
4286
|
limit: targets.length,
|
|
3029
|
-
context:
|
|
4287
|
+
context: SYSTEM_CTX2
|
|
3030
4288
|
});
|
|
3031
4289
|
for (const u of users ?? []) {
|
|
3032
4290
|
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
@@ -3040,7 +4298,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3040
4298
|
where: { email: { $in: unresolvedEmails } },
|
|
3041
4299
|
fields: ["email", "name"],
|
|
3042
4300
|
limit: unresolvedEmails.length,
|
|
3043
|
-
context:
|
|
4301
|
+
context: SYSTEM_CTX2
|
|
3044
4302
|
});
|
|
3045
4303
|
for (const u of users ?? []) {
|
|
3046
4304
|
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
@@ -3066,11 +4324,32 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3066
4324
|
return [];
|
|
3067
4325
|
}
|
|
3068
4326
|
}
|
|
4327
|
+
/**
|
|
4328
|
+
* Field key → display label for an object's schema. Lets the inbox summary
|
|
4329
|
+
* show a human field name ("考核状态") instead of a title-cased machine key
|
|
4330
|
+
* ("Assessment Status"). For a single-locale project the schema label already
|
|
4331
|
+
* IS the localized string; symmetric with `resolveDisplayField`/lookup
|
|
4332
|
+
* resolution that power `payload_display`.
|
|
4333
|
+
*/
|
|
4334
|
+
resolveFieldLabels(object) {
|
|
4335
|
+
try {
|
|
4336
|
+
const schema = this.engine.getSchema?.(object);
|
|
4337
|
+
const fields = schema?.fields ?? {};
|
|
4338
|
+
const out = {};
|
|
4339
|
+
for (const [key, f] of Object.entries(fields)) {
|
|
4340
|
+
if (f?.label) out[key] = String(f.label);
|
|
4341
|
+
}
|
|
4342
|
+
return out;
|
|
4343
|
+
} catch {
|
|
4344
|
+
return {};
|
|
4345
|
+
}
|
|
4346
|
+
}
|
|
3069
4347
|
/**
|
|
3070
4348
|
* Attach inbox display fields to rows so clients never render a raw
|
|
3071
4349
|
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
3072
|
-
* `pending_approver_names` (user-id approvers),
|
|
3073
|
-
* (lookup foreign keys in the snapshot → referenced record titles)
|
|
4350
|
+
* `pending_approver_names` (user-id approvers), `payload_display`
|
|
4351
|
+
* (lookup foreign keys in the snapshot → referenced record titles), and
|
|
4352
|
+
* `payload_labels` (snapshot field keys → the target object's field labels).
|
|
3074
4353
|
* Batched: one query per distinct object (target + referenced) plus one
|
|
3075
4354
|
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
3076
4355
|
* payload snapshot, and any failure leaves the field unset rather than
|
|
@@ -3102,7 +4381,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3102
4381
|
const recs = await this.engine.find(object, {
|
|
3103
4382
|
where: { id: { $in: ids } },
|
|
3104
4383
|
limit: ids.length,
|
|
3105
|
-
context:
|
|
4384
|
+
context: SYSTEM_CTX2
|
|
3106
4385
|
});
|
|
3107
4386
|
for (const rec of recs ?? []) {
|
|
3108
4387
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3112,9 +4391,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3112
4391
|
}
|
|
3113
4392
|
}
|
|
3114
4393
|
const lookupFieldsByObject = /* @__PURE__ */ new Map();
|
|
4394
|
+
const fieldLabelsByObject = /* @__PURE__ */ new Map();
|
|
3115
4395
|
for (const object of byObject.keys()) {
|
|
3116
4396
|
const lookups = this.resolveLookupFields(object);
|
|
3117
4397
|
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
4398
|
+
const labels = this.resolveFieldLabels(object);
|
|
4399
|
+
if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
|
|
3118
4400
|
}
|
|
3119
4401
|
const refIds = /* @__PURE__ */ new Map();
|
|
3120
4402
|
for (const r of rows) {
|
|
@@ -3140,7 +4422,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3140
4422
|
const recs = await this.engine.find(object, {
|
|
3141
4423
|
where: { id: { $in: ids } },
|
|
3142
4424
|
limit: ids.length,
|
|
3143
|
-
context:
|
|
4425
|
+
context: SYSTEM_CTX2
|
|
3144
4426
|
});
|
|
3145
4427
|
for (const rec of recs ?? []) {
|
|
3146
4428
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3181,6 +4463,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3181
4463
|
}
|
|
3182
4464
|
if (Object.keys(display).length) r.payload_display = display;
|
|
3183
4465
|
}
|
|
4466
|
+
const fieldLabels = fieldLabelsByObject.get(r.object_name);
|
|
4467
|
+
if (fieldLabels && r.payload && typeof r.payload === "object") {
|
|
4468
|
+
const labels = {};
|
|
4469
|
+
for (const key of Object.keys(r.payload)) {
|
|
4470
|
+
const l = fieldLabels[key];
|
|
4471
|
+
if (l) labels[key] = l;
|
|
4472
|
+
}
|
|
4473
|
+
if (Object.keys(labels).length) r.payload_labels = labels;
|
|
4474
|
+
}
|
|
3184
4475
|
}
|
|
3185
4476
|
}
|
|
3186
4477
|
// ── Pending-approver index (issue #1745) ─────────────────────
|
|
@@ -3196,12 +4487,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3196
4487
|
const existing = await this.engine.find("sys_approval_approver", {
|
|
3197
4488
|
where: { request_id: requestId },
|
|
3198
4489
|
limit: 500,
|
|
3199
|
-
context:
|
|
4490
|
+
context: SYSTEM_CTX2
|
|
3200
4491
|
});
|
|
3201
4492
|
const rows = Array.isArray(existing) ? existing : [];
|
|
3202
4493
|
for (const row of rows) {
|
|
3203
4494
|
if (desired.has(String(row.approver))) desired.delete(String(row.approver));
|
|
3204
|
-
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4495
|
+
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3205
4496
|
}
|
|
3206
4497
|
for (const approver of desired) {
|
|
3207
4498
|
await this.engine.insert("sys_approval_approver", {
|
|
@@ -3210,7 +4501,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3210
4501
|
approver,
|
|
3211
4502
|
organization_id: org,
|
|
3212
4503
|
created_at: now
|
|
3213
|
-
}, { context:
|
|
4504
|
+
}, { context: SYSTEM_CTX2 });
|
|
3214
4505
|
}
|
|
3215
4506
|
}
|
|
3216
4507
|
/**
|
|
@@ -3228,7 +4519,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3228
4519
|
fields: ["id", "pending_approvers", "organization_id"],
|
|
3229
4520
|
limit: PAGE,
|
|
3230
4521
|
offset,
|
|
3231
|
-
context:
|
|
4522
|
+
context: SYSTEM_CTX2
|
|
3232
4523
|
});
|
|
3233
4524
|
const rows = Array.isArray(batch) ? batch : [];
|
|
3234
4525
|
for (const r of rows) {
|
|
@@ -3245,7 +4536,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3245
4536
|
orderBy: [{ field: "created_at", order: "asc" }],
|
|
3246
4537
|
limit: PAGE,
|
|
3247
4538
|
offset,
|
|
3248
|
-
context:
|
|
4539
|
+
context: SYSTEM_CTX2
|
|
3249
4540
|
});
|
|
3250
4541
|
const rows = Array.isArray(batch) ? batch : [];
|
|
3251
4542
|
indexRows.push(...rows);
|
|
@@ -3259,7 +4550,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3259
4550
|
const want = desired.get(reqId);
|
|
3260
4551
|
const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
|
|
3261
4552
|
if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
|
|
3262
|
-
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4553
|
+
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3263
4554
|
deleted++;
|
|
3264
4555
|
continue;
|
|
3265
4556
|
}
|
|
@@ -3276,7 +4567,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3276
4567
|
approver,
|
|
3277
4568
|
organization_id: want.org,
|
|
3278
4569
|
created_at: now
|
|
3279
|
-
}, { context:
|
|
4570
|
+
}, { context: SYSTEM_CTX2 });
|
|
3280
4571
|
inserted++;
|
|
3281
4572
|
}
|
|
3282
4573
|
}
|
|
@@ -3326,7 +4617,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3326
4617
|
where,
|
|
3327
4618
|
fields: ["request_id"],
|
|
3328
4619
|
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
3329
|
-
context:
|
|
4620
|
+
context: SYSTEM_CTX2
|
|
3330
4621
|
});
|
|
3331
4622
|
const list = Array.isArray(rows) ? rows : [];
|
|
3332
4623
|
if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
|
|
@@ -3337,6 +4628,88 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3337
4628
|
}
|
|
3338
4629
|
return [...new Set(list.map((r) => String(r.request_id)))];
|
|
3339
4630
|
}
|
|
4631
|
+
/**
|
|
4632
|
+
* The request ids this caller is a PARTICIPANT of, or `null` for a caller
|
|
4633
|
+
* who may see everything in scope (#3590).
|
|
4634
|
+
*
|
|
4635
|
+
* These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
|
|
4636
|
+
* approver-visibility rule spans several identity forms that RLS cannot model
|
|
4637
|
+
* cleanly, which is why it has to be expressed here. Until now only the
|
|
4638
|
+
* TENANT half of that rule was applied, so any authenticated user could read
|
|
4639
|
+
* any request in their tenant (and, once attachments derived their access
|
|
4640
|
+
* from the request, its files too). This adds the participant half.
|
|
4641
|
+
*
|
|
4642
|
+
* A participant is the submitter, a current approver, or someone who has
|
|
4643
|
+
* already acted on the request (a past approver whose slot has moved on, a
|
|
4644
|
+
* commenter). Admins with override authority keep the unrestricted view the
|
|
4645
|
+
* "all requests" console surface depends on.
|
|
4646
|
+
*
|
|
4647
|
+
* Keying on the concrete user id is sufficient rather than an approximation:
|
|
4648
|
+
* position/team/manager/field approvers are resolved to concrete user ids at
|
|
4649
|
+
* open time, and the `type:value` literal is only the fallback for a spec
|
|
4650
|
+
* that resolved to NOBODY — a slot no one can act on either way (`can_act`
|
|
4651
|
+
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
4652
|
+
* request from someone who could actually act on it.
|
|
4653
|
+
*/
|
|
4654
|
+
async visibleRequestIds(context, tenantOrg) {
|
|
4655
|
+
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
4656
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
4657
|
+
if (!uid2) return /* @__PURE__ */ new Set();
|
|
4658
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4659
|
+
const cap = _ApprovalService.APPROVER_INDEX_CAP;
|
|
4660
|
+
const add = (rows, key) => {
|
|
4661
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
4662
|
+
for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
|
|
4663
|
+
if (list.length >= cap) {
|
|
4664
|
+
this.logger?.warn?.(
|
|
4665
|
+
"[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
|
|
4666
|
+
{ cap, key }
|
|
4667
|
+
);
|
|
4668
|
+
}
|
|
4669
|
+
};
|
|
4670
|
+
try {
|
|
4671
|
+
for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
|
|
4672
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
4673
|
+
add(
|
|
4674
|
+
await this.engine.find("sys_approval_request", {
|
|
4675
|
+
where: { submitter_id: uid2, ...orgWhere },
|
|
4676
|
+
fields: ["id"],
|
|
4677
|
+
limit: cap,
|
|
4678
|
+
context: SYSTEM_CTX2
|
|
4679
|
+
}),
|
|
4680
|
+
"id"
|
|
4681
|
+
);
|
|
4682
|
+
add(
|
|
4683
|
+
await this.engine.find("sys_approval_action", {
|
|
4684
|
+
where: { actor_id: uid2 },
|
|
4685
|
+
fields: ["request_id"],
|
|
4686
|
+
limit: cap,
|
|
4687
|
+
context: SYSTEM_CTX2
|
|
4688
|
+
}),
|
|
4689
|
+
"request_id"
|
|
4690
|
+
);
|
|
4691
|
+
} catch (err) {
|
|
4692
|
+
this.logger?.warn?.("[approvals] participant-visibility probe failed", {
|
|
4693
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4694
|
+
});
|
|
4695
|
+
}
|
|
4696
|
+
return ids;
|
|
4697
|
+
}
|
|
4698
|
+
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
4699
|
+
applyVisibility(where, visible) {
|
|
4700
|
+
if (!visible) return true;
|
|
4701
|
+
if (visible.size === 0) return false;
|
|
4702
|
+
let allowed = [...visible];
|
|
4703
|
+
const current = where.id;
|
|
4704
|
+
if (typeof current === "string") allowed = allowed.filter((x) => x === current);
|
|
4705
|
+
else if (current && typeof current === "object" && Array.isArray(current.$in)) {
|
|
4706
|
+
const set = new Set(current.$in.map((v) => String(v)));
|
|
4707
|
+
allowed = allowed.filter((x) => set.has(x));
|
|
4708
|
+
}
|
|
4709
|
+
if (allowed.length === 0) return false;
|
|
4710
|
+
where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
|
|
4711
|
+
return true;
|
|
4712
|
+
}
|
|
3340
4713
|
async listRequests(filter, context) {
|
|
3341
4714
|
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
3342
4715
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
@@ -3345,10 +4718,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3345
4718
|
if (ids.length === 0) return [];
|
|
3346
4719
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3347
4720
|
}
|
|
4721
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
|
|
3348
4722
|
const findOpts = {
|
|
3349
4723
|
where,
|
|
3350
4724
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
3351
|
-
context:
|
|
4725
|
+
context: SYSTEM_CTX2
|
|
3352
4726
|
};
|
|
3353
4727
|
if (filter?.limit != null || filter?.offset != null) {
|
|
3354
4728
|
findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
|
|
@@ -3370,10 +4744,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3370
4744
|
if (ids.length === 0) return 0;
|
|
3371
4745
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3372
4746
|
}
|
|
4747
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
|
|
3373
4748
|
const countFn = this.engine.count;
|
|
3374
4749
|
if (typeof countFn === "function") {
|
|
3375
4750
|
try {
|
|
3376
|
-
const n = await countFn.call(this.engine, "sys_approval_request", { where, context:
|
|
4751
|
+
const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
|
|
3377
4752
|
if (typeof n === "number") return n;
|
|
3378
4753
|
} catch {
|
|
3379
4754
|
}
|
|
@@ -3382,11 +4757,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3382
4757
|
where,
|
|
3383
4758
|
fields: ["id"],
|
|
3384
4759
|
limit: ids ? Math.max(500, ids.length) : 500,
|
|
3385
|
-
context:
|
|
4760
|
+
context: SYSTEM_CTX2
|
|
3386
4761
|
});
|
|
3387
4762
|
return Array.isArray(rows) ? rows.length : 0;
|
|
3388
4763
|
}
|
|
4764
|
+
/**
|
|
4765
|
+
* Read the request a write path just changed, to echo back as its result.
|
|
4766
|
+
*
|
|
4767
|
+
* NOT participant-gated (#3590), deliberately: the operation authorized
|
|
4768
|
+
* itself by its own rule before writing, so re-asking "may you see this?"
|
|
4769
|
+
* for the echo answers a question that has already been settled — and would
|
|
4770
|
+
* answer it WRONG for a caller context that carries no `userId` (a
|
|
4771
|
+
* flow-driven resume, a service-to-service call), turning a successful write
|
|
4772
|
+
* into a `null` result. Gating belongs on the read API, not on an
|
|
4773
|
+
* operation's own return value.
|
|
4774
|
+
*/
|
|
4775
|
+
async readBackRequest(requestId, context) {
|
|
4776
|
+
return this.loadRequest(requestId, context, false);
|
|
4777
|
+
}
|
|
3389
4778
|
async getRequest(requestId, context) {
|
|
4779
|
+
return this.loadRequest(requestId, context, true);
|
|
4780
|
+
}
|
|
4781
|
+
async loadRequest(requestId, context, enforceVisibility) {
|
|
3390
4782
|
if (!requestId) return null;
|
|
3391
4783
|
const where = { id: requestId };
|
|
3392
4784
|
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
@@ -3394,9 +4786,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3394
4786
|
const rows = await this.engine.find("sys_approval_request", {
|
|
3395
4787
|
where,
|
|
3396
4788
|
limit: 1,
|
|
3397
|
-
context:
|
|
4789
|
+
context: SYSTEM_CTX2
|
|
3398
4790
|
});
|
|
3399
4791
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
4792
|
+
if (enforceVisibility) {
|
|
4793
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
|
|
4794
|
+
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
4795
|
+
}
|
|
3400
4796
|
const row = rowFromRequest(rows[0]);
|
|
3401
4797
|
await this.enrichRows([row]);
|
|
3402
4798
|
await this.attachFlowSteps(row);
|
|
@@ -3423,7 +4819,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3423
4819
|
const acts = await this.engine.find("sys_approval_action", {
|
|
3424
4820
|
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3425
4821
|
limit: 1e3,
|
|
3426
|
-
context:
|
|
4822
|
+
context: SYSTEM_CTX2
|
|
3427
4823
|
});
|
|
3428
4824
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3429
4825
|
const snapshot = cfg?.__approverGroups;
|
|
@@ -3444,6 +4840,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3444
4840
|
});
|
|
3445
4841
|
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3446
4842
|
progress.need = progress.groups.length;
|
|
4843
|
+
const pendingGroups = {};
|
|
4844
|
+
for (const a of row.pending_approvers ?? []) {
|
|
4845
|
+
const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
|
|
4846
|
+
if (named.length) pendingGroups[a] = named;
|
|
4847
|
+
}
|
|
4848
|
+
if (Object.keys(pendingGroups).length) {
|
|
4849
|
+
row.pending_approver_groups = pendingGroups;
|
|
4850
|
+
}
|
|
3447
4851
|
}
|
|
3448
4852
|
row.decision_progress = progress;
|
|
3449
4853
|
} catch {
|
|
@@ -3455,8 +4859,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3455
4859
|
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
3456
4860
|
* still `pending` (position/team/manager approvers are already resolved to
|
|
3457
4861
|
* concrete user ids at open time, so a plain membership test is faithful).
|
|
3458
|
-
* `is_submitter` is a straight owner check.
|
|
3459
|
-
*
|
|
4862
|
+
* `is_submitter` is a straight owner check. `can_override` (#3424) is true for
|
|
4863
|
+
* a platform/tenant admin on a PENDING request — the recovery path for an
|
|
4864
|
+
* approval routed to an unstaffed position or to approvers who have all left;
|
|
4865
|
+
* clients OR it into the decision actions' `visible` gate so an admin can act
|
|
4866
|
+
* even when they hold no slot. System/tokenless contexts get a both-false
|
|
4867
|
+
* `can_act`/`is_submitter` block (system gets `can_override` too — it may act
|
|
4868
|
+
* on anything). Cheap + synchronous — safe on list reads.
|
|
3460
4869
|
*/
|
|
3461
4870
|
attachViewers(rows, context) {
|
|
3462
4871
|
const uid2 = context?.userId != null ? String(context.userId) : null;
|
|
@@ -3464,7 +4873,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3464
4873
|
const pending = row.pending_approvers ?? [];
|
|
3465
4874
|
row.viewer = {
|
|
3466
4875
|
can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
|
|
3467
|
-
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
|
|
4876
|
+
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
|
|
4877
|
+
can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
|
|
3468
4878
|
};
|
|
3469
4879
|
}
|
|
3470
4880
|
}
|
|
@@ -3511,7 +4921,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3511
4921
|
where: { request_id: requestId },
|
|
3512
4922
|
limit: 500,
|
|
3513
4923
|
orderBy: [{ field: "created_at", order: "asc" }],
|
|
3514
|
-
context:
|
|
4924
|
+
context: SYSTEM_CTX2
|
|
3515
4925
|
});
|
|
3516
4926
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
3517
4927
|
const names = await this.resolveUserNames(
|
|
@@ -3523,14 +4933,44 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3523
4933
|
}
|
|
3524
4934
|
return actions;
|
|
3525
4935
|
}
|
|
4936
|
+
/**
|
|
4937
|
+
* `IFileAccessDelegate` — may this caller download a decision attachment?
|
|
4938
|
+
* (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
|
|
4939
|
+
*
|
|
4940
|
+
* A file referenced by `sys_approval_action.attachments` is owned by that
|
|
4941
|
+
* audit row, so the storage service would otherwise authorize the download by
|
|
4942
|
+
* testing whether the caller can READ the row. It cannot: `sys_approval_action`
|
|
4943
|
+
* is deliberately closed to ordinary approver positions, so that test denies
|
|
4944
|
+
* the very approver the attachment was filed for.
|
|
4945
|
+
*
|
|
4946
|
+
* The rule that actually governs seeing a decision is the one `listActions`
|
|
4947
|
+
* applies — can the caller see the PARENT REQUEST? — so this reuses it
|
|
4948
|
+
* exactly, rather than inventing a second, looser rule for the bytes. Fails
|
|
4949
|
+
* closed on any error.
|
|
4950
|
+
*/
|
|
4951
|
+
async authorizeFileRead(actionId, context) {
|
|
4952
|
+
if (!actionId) return false;
|
|
4953
|
+
try {
|
|
4954
|
+
const rows = await this.engine.find("sys_approval_action", {
|
|
4955
|
+
where: { id: actionId },
|
|
4956
|
+
limit: 1,
|
|
4957
|
+
context: SYSTEM_CTX2
|
|
4958
|
+
});
|
|
4959
|
+
const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
|
|
4960
|
+
if (!requestId) return false;
|
|
4961
|
+
return !!await this.getRequest(String(requestId), context);
|
|
4962
|
+
} catch {
|
|
4963
|
+
return false;
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
3526
4966
|
};
|
|
3527
4967
|
/** Window the approver-index probe — pending queues live far below this. */
|
|
3528
4968
|
_ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
3529
4969
|
var ApprovalService = _ApprovalService;
|
|
3530
4970
|
|
|
3531
4971
|
// src/sys-approval-token.object.ts
|
|
3532
|
-
var
|
|
3533
|
-
var SysApprovalToken =
|
|
4972
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
4973
|
+
var SysApprovalToken = import_data6.ObjectSchema.create({
|
|
3534
4974
|
name: "sys_approval_token",
|
|
3535
4975
|
label: "Approval Action Token",
|
|
3536
4976
|
pluralLabel: "Approval Action Tokens",
|
|
@@ -3542,13 +4982,13 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
|
3542
4982
|
nameField: "id",
|
|
3543
4983
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
3544
4984
|
fields: {
|
|
3545
|
-
id:
|
|
3546
|
-
organization_id:
|
|
4985
|
+
id: import_data6.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
|
|
4986
|
+
organization_id: import_data6.Field.lookup("sys_organization", {
|
|
3547
4987
|
label: "Organization",
|
|
3548
4988
|
required: false,
|
|
3549
4989
|
group: "System"
|
|
3550
4990
|
}),
|
|
3551
|
-
token_hash:
|
|
4991
|
+
token_hash: import_data6.Field.text({
|
|
3552
4992
|
label: "Token Hash",
|
|
3553
4993
|
required: true,
|
|
3554
4994
|
maxLength: 100,
|
|
@@ -3556,20 +4996,20 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
|
3556
4996
|
description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
|
|
3557
4997
|
group: "Token"
|
|
3558
4998
|
}),
|
|
3559
|
-
request_id:
|
|
4999
|
+
request_id: import_data6.Field.text({
|
|
3560
5000
|
label: "Request",
|
|
3561
5001
|
required: true,
|
|
3562
5002
|
maxLength: 100,
|
|
3563
5003
|
readonly: true,
|
|
3564
5004
|
group: "Token"
|
|
3565
5005
|
}),
|
|
3566
|
-
action:
|
|
5006
|
+
action: import_data6.Field.select(["approve", "reject"], {
|
|
3567
5007
|
label: "Action",
|
|
3568
5008
|
required: true,
|
|
3569
5009
|
readonly: true,
|
|
3570
5010
|
group: "Token"
|
|
3571
5011
|
}),
|
|
3572
|
-
approver_id:
|
|
5012
|
+
approver_id: import_data6.Field.text({
|
|
3573
5013
|
label: "Approver",
|
|
3574
5014
|
required: true,
|
|
3575
5015
|
maxLength: 200,
|
|
@@ -3577,18 +5017,18 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
|
3577
5017
|
description: "Identity the token is bound to; the decision is audited as this approver",
|
|
3578
5018
|
group: "Token"
|
|
3579
5019
|
}),
|
|
3580
|
-
expires_at:
|
|
5020
|
+
expires_at: import_data6.Field.datetime({
|
|
3581
5021
|
label: "Expires At",
|
|
3582
5022
|
required: true,
|
|
3583
5023
|
readonly: true,
|
|
3584
5024
|
group: "Lifecycle"
|
|
3585
5025
|
}),
|
|
3586
|
-
consumed_at:
|
|
5026
|
+
consumed_at: import_data6.Field.datetime({
|
|
3587
5027
|
label: "Consumed At",
|
|
3588
5028
|
required: false,
|
|
3589
5029
|
group: "Lifecycle"
|
|
3590
5030
|
}),
|
|
3591
|
-
created_at:
|
|
5031
|
+
created_at: import_data6.Field.datetime({
|
|
3592
5032
|
label: "Created At",
|
|
3593
5033
|
required: true,
|
|
3594
5034
|
defaultValue: "NOW()",
|
|
@@ -3717,6 +5157,8 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
3717
5157
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3718
5158
|
const pending = await pendingRequestFor(engine, object, id);
|
|
3719
5159
|
if (!pending) return;
|
|
5160
|
+
const writerRun = ctx?.provenance?.flowRunId;
|
|
5161
|
+
if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
|
|
3720
5162
|
const config = parseJson2(pending.node_config_json, {});
|
|
3721
5163
|
if (config?.lockRecord === false) return;
|
|
3722
5164
|
const mirror = config?.approvalStatusField;
|
|
@@ -3768,7 +5210,22 @@ function unbindAllHooks(engine) {
|
|
|
3768
5210
|
|
|
3769
5211
|
// src/approval-node.ts
|
|
3770
5212
|
var import_automation2 = require("@objectstack/spec/automation");
|
|
3771
|
-
var
|
|
5213
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5214
|
+
function nestVariables(variables) {
|
|
5215
|
+
const vars = {};
|
|
5216
|
+
for (const [key, value] of variables) {
|
|
5217
|
+
const segs = key.split(".");
|
|
5218
|
+
let cursor = vars;
|
|
5219
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
5220
|
+
if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
|
|
5221
|
+
cursor[segs[i]] = {};
|
|
5222
|
+
}
|
|
5223
|
+
cursor = cursor[segs[i]];
|
|
5224
|
+
}
|
|
5225
|
+
cursor[segs[segs.length - 1]] = value;
|
|
5226
|
+
}
|
|
5227
|
+
return vars;
|
|
5228
|
+
}
|
|
3772
5229
|
function registerApprovalNode(automation, service, logger) {
|
|
3773
5230
|
automation.registerNodeExecutor({
|
|
3774
5231
|
type: import_automation2.APPROVAL_NODE_TYPE,
|
|
@@ -3784,6 +5241,15 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3784
5241
|
// Human decision: the run suspends here awaiting an external reply.
|
|
3785
5242
|
supportsPause: true,
|
|
3786
5243
|
isAsync: true,
|
|
5244
|
+
// #3801: this pause is NOT resumable through the generic run-resume
|
|
5245
|
+
// route. Continuing an approval is a side effect of a DECISION, and the
|
|
5246
|
+
// decision is the thing that must be authorized (the approver slate),
|
|
5247
|
+
// recorded (`sys_approval_action`) and mirrored (the status field) —
|
|
5248
|
+
// all of which lives in `ApprovalService.decide`. The engine now refuses
|
|
5249
|
+
// any resume of an approval suspension that does not carry the service's
|
|
5250
|
+
// in-process marker, so "decide via the approvals API, never a raw engine
|
|
5251
|
+
// resume" is enforced rather than merely documented.
|
|
5252
|
+
resumeAuthority: "service",
|
|
3787
5253
|
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
3788
5254
|
// Studio flow designer renders the Approval property form from the engine
|
|
3789
5255
|
// rather than a hardcoded client form — the engine owns the shape.
|
|
@@ -3817,13 +5283,28 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3817
5283
|
nodeLabel: typeof node.label === "string" ? node.label : void 0,
|
|
3818
5284
|
submitterId: context?.userId ?? null,
|
|
3819
5285
|
record,
|
|
3820
|
-
organizationId: context?.organizationId ?? context?.tenantId ?? null
|
|
5286
|
+
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
5287
|
+
// #3447 P2: flow variables (nested, as CEL conditions see them) — the
|
|
5288
|
+
// `vars.*` root for `expression` approvers; `record` above doubles as
|
|
5289
|
+
// their `trigger.*` snapshot root.
|
|
5290
|
+
variables: nestVariables(variables)
|
|
3821
5291
|
}, {
|
|
3822
|
-
...
|
|
5292
|
+
...SYSTEM_CTX3,
|
|
3823
5293
|
userId: context?.userId,
|
|
3824
5294
|
organizationId: context?.organizationId,
|
|
3825
5295
|
tenantId: context?.tenantId
|
|
3826
5296
|
});
|
|
5297
|
+
if ("autoApproved" in request) {
|
|
5298
|
+
logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
|
|
5299
|
+
node: node.id,
|
|
5300
|
+
run: String(runId)
|
|
5301
|
+
});
|
|
5302
|
+
return {
|
|
5303
|
+
success: true,
|
|
5304
|
+
branchLabel: "approve",
|
|
5305
|
+
output: { decision: "approve", autoApproved: true }
|
|
5306
|
+
};
|
|
5307
|
+
}
|
|
3827
5308
|
logger?.info?.("[approvals] approval node suspended run", {
|
|
3828
5309
|
node: node.id,
|
|
3829
5310
|
request: request.id,
|
|
@@ -3909,7 +5390,21 @@ var ApprovalsServicePlugin = class {
|
|
|
3909
5390
|
this.service = new ApprovalService({
|
|
3910
5391
|
engine,
|
|
3911
5392
|
logger: ctx.logger,
|
|
3912
|
-
publicBaseUrl: this.options.publicBaseUrl
|
|
5393
|
+
publicBaseUrl: this.options.publicBaseUrl,
|
|
5394
|
+
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5395
|
+
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5396
|
+
// service resolves its posture during its own start, which may not have
|
|
5397
|
+
// run yet; an unresolvable posture reads as "unknown" and the guard
|
|
5398
|
+
// stands down rather than refusing a legitimate flow on a minimal stack.
|
|
5399
|
+
tenancyPosture: () => {
|
|
5400
|
+
try {
|
|
5401
|
+
const tenancy = ctx.getService("tenancy");
|
|
5402
|
+
const posture = tenancy?.posture;
|
|
5403
|
+
return typeof posture === "string" && posture ? posture : void 0;
|
|
5404
|
+
} catch {
|
|
5405
|
+
return void 0;
|
|
5406
|
+
}
|
|
5407
|
+
}
|
|
3913
5408
|
});
|
|
3914
5409
|
if (!this.options.disableAutoHooks) {
|
|
3915
5410
|
try {
|
|
@@ -3935,12 +5430,23 @@ var ApprovalsServicePlugin = class {
|
|
|
3935
5430
|
if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
|
|
3936
5431
|
const svc = this.service;
|
|
3937
5432
|
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
|
|
3938
|
-
|
|
3939
|
-
await
|
|
3940
|
-
|
|
5433
|
+
const sweep = async () => {
|
|
5434
|
+
const results = await Promise.allSettled([
|
|
5435
|
+
svc.runEscalations(),
|
|
5436
|
+
svc.releaseDeadRunRequests()
|
|
5437
|
+
]);
|
|
5438
|
+
for (const r of results) {
|
|
5439
|
+
if (r.status === "rejected") {
|
|
5440
|
+
ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
|
|
5441
|
+
error: r.reason?.message ?? String(r.reason)
|
|
5442
|
+
});
|
|
5443
|
+
}
|
|
5444
|
+
}
|
|
5445
|
+
};
|
|
5446
|
+
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
3941
5447
|
this.escalationJobScheduled = true;
|
|
3942
|
-
void
|
|
3943
|
-
ctx.logger.warn?.("[approvals] boot
|
|
5448
|
+
void sweep().catch((err) => {
|
|
5449
|
+
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
3944
5450
|
});
|
|
3945
5451
|
ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
|
|
3946
5452
|
} catch {
|