@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1462 -0
- package/dist/index.d.mts +2853 -3925
- package/dist/index.d.ts +2853 -3925
- package/dist/index.js +1819 -240
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1817 -230
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -7
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -33
- package/src/action-link-pages.ts +0 -102
- package/src/approval-node.test.ts +0 -196
- package/src/approval-node.ts +0 -139
- package/src/approval-revise.test.ts +0 -411
- package/src/approval-service.test.ts +0 -1490
- package/src/approval-service.ts +0 -2360
- package/src/approvals-plugin.ts +0 -263
- package/src/index.ts +0 -39
- package/src/lifecycle-hooks.ts +0 -179
- package/src/nav-contribution.test.ts +0 -50
- package/src/sys-approval-action.object.ts +0 -140
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -103
- package/src/sys-approval-request.object.ts +0 -401
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/en.objects.generated.ts +0 -205
- package/src/translations/es-ES.objects.generated.ts +0 -205
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -205
- package/src/translations/zh-CN.objects.generated.ts +0 -205
- package/tsconfig.json +0 -10
package/dist/index.mjs
CHANGED
|
@@ -54,7 +54,8 @@ var init_en_objects_generated = __esm({
|
|
|
54
54
|
pending: "pending",
|
|
55
55
|
approved: "approved",
|
|
56
56
|
rejected: "rejected",
|
|
57
|
-
recalled: "recalled"
|
|
57
|
+
recalled: "recalled",
|
|
58
|
+
returned: "returned"
|
|
58
59
|
}
|
|
59
60
|
},
|
|
60
61
|
current_step: {
|
|
@@ -96,7 +97,11 @@ var init_en_objects_generated = __esm({
|
|
|
96
97
|
},
|
|
97
98
|
_views: {
|
|
98
99
|
my_pending: {
|
|
99
|
-
label: "My Pending"
|
|
100
|
+
label: "My Pending",
|
|
101
|
+
emptyState: {
|
|
102
|
+
title: "No pending approvals",
|
|
103
|
+
message: "You're all caught up."
|
|
104
|
+
}
|
|
100
105
|
},
|
|
101
106
|
submitted_by_me: {
|
|
102
107
|
label: "I Submitted"
|
|
@@ -107,6 +112,92 @@ var init_en_objects_generated = __esm({
|
|
|
107
112
|
all_requests: {
|
|
108
113
|
label: "All"
|
|
109
114
|
}
|
|
115
|
+
},
|
|
116
|
+
_actions: {
|
|
117
|
+
approval_approve: {
|
|
118
|
+
label: "Approve",
|
|
119
|
+
successMessage: "Approved.",
|
|
120
|
+
params: {
|
|
121
|
+
comment: {
|
|
122
|
+
label: "Comment"
|
|
123
|
+
},
|
|
124
|
+
attachments: {
|
|
125
|
+
label: "Attachments"
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
approval_reject: {
|
|
130
|
+
label: "Reject",
|
|
131
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
132
|
+
successMessage: "Rejected.",
|
|
133
|
+
params: {
|
|
134
|
+
comment: {
|
|
135
|
+
label: "Comment"
|
|
136
|
+
},
|
|
137
|
+
attachments: {
|
|
138
|
+
label: "Attachments"
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
approval_reassign: {
|
|
143
|
+
label: "Reassign",
|
|
144
|
+
successMessage: "Reassigned.",
|
|
145
|
+
params: {
|
|
146
|
+
to: {
|
|
147
|
+
label: "New approver",
|
|
148
|
+
helpText: "User to hand this step to"
|
|
149
|
+
},
|
|
150
|
+
comment: {
|
|
151
|
+
label: "Comment"
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
approval_send_back: {
|
|
156
|
+
label: "Send back",
|
|
157
|
+
successMessage: "Sent back for revision.",
|
|
158
|
+
params: {
|
|
159
|
+
comment: {
|
|
160
|
+
label: "Reason"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
approval_request_info: {
|
|
165
|
+
label: "Request info",
|
|
166
|
+
successMessage: "Information requested.",
|
|
167
|
+
params: {
|
|
168
|
+
comment: {
|
|
169
|
+
label: "What do you need?"
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
approval_remind: {
|
|
174
|
+
label: "Send reminder",
|
|
175
|
+
successMessage: "Reminder sent.",
|
|
176
|
+
params: {
|
|
177
|
+
comment: {
|
|
178
|
+
label: "Note"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
approval_recall: {
|
|
183
|
+
label: "Recall",
|
|
184
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
185
|
+
successMessage: "Recalled.",
|
|
186
|
+
params: {
|
|
187
|
+
comment: {
|
|
188
|
+
label: "Comment"
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
approval_resubmit: {
|
|
193
|
+
label: "Resubmit",
|
|
194
|
+
successMessage: "Resubmitted.",
|
|
195
|
+
params: {
|
|
196
|
+
comment: {
|
|
197
|
+
label: "What changed?"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
110
201
|
}
|
|
111
202
|
},
|
|
112
203
|
sys_approval_action: {
|
|
@@ -138,7 +229,14 @@ var init_en_objects_generated = __esm({
|
|
|
138
229
|
approve: "approve",
|
|
139
230
|
reject: "reject",
|
|
140
231
|
recall: "recall",
|
|
141
|
-
escalate: "escalate"
|
|
232
|
+
escalate: "escalate",
|
|
233
|
+
reassign: "reassign",
|
|
234
|
+
remind: "remind",
|
|
235
|
+
request_info: "request_info",
|
|
236
|
+
comment: "comment",
|
|
237
|
+
revise: "revise",
|
|
238
|
+
resubmit: "resubmit",
|
|
239
|
+
ooo_substitute: "ooo_substitute"
|
|
142
240
|
}
|
|
143
241
|
},
|
|
144
242
|
actor_id: {
|
|
@@ -147,13 +245,29 @@ var init_en_objects_generated = __esm({
|
|
|
147
245
|
comment: {
|
|
148
246
|
label: "Comment"
|
|
149
247
|
},
|
|
248
|
+
reassign_from: {
|
|
249
|
+
label: "Reassigned From",
|
|
250
|
+
help: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
251
|
+
},
|
|
252
|
+
reassign_to: {
|
|
253
|
+
label: "Reassigned To",
|
|
254
|
+
help: "User who received the pending-approver slot (reassign actions only)"
|
|
255
|
+
},
|
|
256
|
+
attachments: {
|
|
257
|
+
label: "Attachments",
|
|
258
|
+
help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
259
|
+
},
|
|
150
260
|
created_at: {
|
|
151
261
|
label: "Created At"
|
|
152
262
|
}
|
|
153
263
|
},
|
|
154
264
|
_views: {
|
|
155
265
|
recent: {
|
|
156
|
-
label: "Recent"
|
|
266
|
+
label: "Recent",
|
|
267
|
+
emptyState: {
|
|
268
|
+
title: "No approval actions yet",
|
|
269
|
+
message: "Actions are logged automatically when approvals progress."
|
|
270
|
+
}
|
|
157
271
|
},
|
|
158
272
|
by_actor: {
|
|
159
273
|
label: "By Actor"
|
|
@@ -257,7 +371,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
257
371
|
pending: "\u5F85\u5904\u7406",
|
|
258
372
|
approved: "\u5DF2\u6279\u51C6",
|
|
259
373
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
260
|
-
recalled: "\u5DF2\u64A4\u56DE"
|
|
374
|
+
recalled: "\u5DF2\u64A4\u56DE",
|
|
375
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
261
376
|
}
|
|
262
377
|
},
|
|
263
378
|
current_step: {
|
|
@@ -299,7 +414,11 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
299
414
|
},
|
|
300
415
|
_views: {
|
|
301
416
|
my_pending: {
|
|
302
|
-
label: "\u6211\u7684\u5F85\u529E"
|
|
417
|
+
label: "\u6211\u7684\u5F85\u529E",
|
|
418
|
+
emptyState: {
|
|
419
|
+
title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
|
|
420
|
+
message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
|
|
421
|
+
}
|
|
303
422
|
},
|
|
304
423
|
submitted_by_me: {
|
|
305
424
|
label: "\u6211\u63D0\u4EA4\u7684"
|
|
@@ -310,6 +429,92 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
310
429
|
all_requests: {
|
|
311
430
|
label: "\u5168\u90E8"
|
|
312
431
|
}
|
|
432
|
+
},
|
|
433
|
+
_actions: {
|
|
434
|
+
approval_approve: {
|
|
435
|
+
label: "\u901A\u8FC7",
|
|
436
|
+
successMessage: "\u5DF2\u901A\u8FC7\u3002",
|
|
437
|
+
params: {
|
|
438
|
+
comment: {
|
|
439
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
440
|
+
},
|
|
441
|
+
attachments: {
|
|
442
|
+
label: "\u9644\u4EF6"
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
},
|
|
446
|
+
approval_reject: {
|
|
447
|
+
label: "\u62D2\u7EDD",
|
|
448
|
+
confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
|
|
449
|
+
successMessage: "\u5DF2\u62D2\u7EDD\u3002",
|
|
450
|
+
params: {
|
|
451
|
+
comment: {
|
|
452
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
453
|
+
},
|
|
454
|
+
attachments: {
|
|
455
|
+
label: "\u9644\u4EF6"
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
approval_reassign: {
|
|
460
|
+
label: "\u8F6C\u7B7E",
|
|
461
|
+
successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
|
|
462
|
+
params: {
|
|
463
|
+
to: {
|
|
464
|
+
label: "\u65B0\u5BA1\u6279\u4EBA",
|
|
465
|
+
helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
|
|
466
|
+
},
|
|
467
|
+
comment: {
|
|
468
|
+
label: "\u5907\u6CE8"
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
approval_send_back: {
|
|
473
|
+
label: "\u9000\u56DE\u4FEE\u6539",
|
|
474
|
+
successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
|
|
475
|
+
params: {
|
|
476
|
+
comment: {
|
|
477
|
+
label: "\u539F\u56E0"
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
approval_request_info: {
|
|
482
|
+
label: "\u9000\u56DE\u8865\u5145",
|
|
483
|
+
successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
|
|
484
|
+
params: {
|
|
485
|
+
comment: {
|
|
486
|
+
label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
approval_remind: {
|
|
491
|
+
label: "\u50AC\u529E",
|
|
492
|
+
successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
|
|
493
|
+
params: {
|
|
494
|
+
comment: {
|
|
495
|
+
label: "\u5907\u6CE8"
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
},
|
|
499
|
+
approval_recall: {
|
|
500
|
+
label: "\u64A4\u56DE",
|
|
501
|
+
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",
|
|
502
|
+
successMessage: "\u5DF2\u64A4\u56DE\u3002",
|
|
503
|
+
params: {
|
|
504
|
+
comment: {
|
|
505
|
+
label: "\u5907\u6CE8"
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
approval_resubmit: {
|
|
510
|
+
label: "\u91CD\u65B0\u63D0\u4EA4",
|
|
511
|
+
successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
|
|
512
|
+
params: {
|
|
513
|
+
comment: {
|
|
514
|
+
label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
313
518
|
}
|
|
314
519
|
},
|
|
315
520
|
sys_approval_action: {
|
|
@@ -341,7 +546,14 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
341
546
|
approve: "\u6279\u51C6",
|
|
342
547
|
reject: "\u62D2\u7EDD",
|
|
343
548
|
recall: "\u64A4\u56DE",
|
|
344
|
-
escalate: "\u5347\u7EA7"
|
|
549
|
+
escalate: "\u5347\u7EA7",
|
|
550
|
+
reassign: "\u8F6C\u7B7E",
|
|
551
|
+
remind: "\u50AC\u529E",
|
|
552
|
+
request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
|
|
553
|
+
comment: "\u8BC4\u8BBA",
|
|
554
|
+
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
555
|
+
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
556
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
345
557
|
}
|
|
346
558
|
},
|
|
347
559
|
actor_id: {
|
|
@@ -350,13 +562,29 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
350
562
|
comment: {
|
|
351
563
|
label: "\u8BC4\u8BBA"
|
|
352
564
|
},
|
|
565
|
+
reassign_from: {
|
|
566
|
+
label: "\u8F6C\u51FA\u4EBA",
|
|
567
|
+
help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
568
|
+
},
|
|
569
|
+
reassign_to: {
|
|
570
|
+
label: "\u8F6C\u5165\u4EBA",
|
|
571
|
+
help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
572
|
+
},
|
|
573
|
+
attachments: {
|
|
574
|
+
label: "\u9644\u4EF6",
|
|
575
|
+
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"
|
|
576
|
+
},
|
|
353
577
|
created_at: {
|
|
354
578
|
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
355
579
|
}
|
|
356
580
|
},
|
|
357
581
|
_views: {
|
|
358
582
|
recent: {
|
|
359
|
-
label: "\u6700\u8FD1"
|
|
583
|
+
label: "\u6700\u8FD1",
|
|
584
|
+
emptyState: {
|
|
585
|
+
title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
|
|
586
|
+
message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
|
|
587
|
+
}
|
|
360
588
|
},
|
|
361
589
|
by_actor: {
|
|
362
590
|
label: "\u6309\u6267\u884C\u4EBA"
|
|
@@ -460,7 +688,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
460
688
|
pending: "\u4FDD\u7559\u4E2D",
|
|
461
689
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
462
690
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
463
|
-
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
|
|
691
|
+
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
692
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
464
693
|
}
|
|
465
694
|
},
|
|
466
695
|
current_step: {
|
|
@@ -502,7 +731,11 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
502
731
|
},
|
|
503
732
|
_views: {
|
|
504
733
|
my_pending: {
|
|
505
|
-
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
|
|
734
|
+
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
|
|
735
|
+
emptyState: {
|
|
736
|
+
title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
|
|
737
|
+
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"
|
|
738
|
+
}
|
|
506
739
|
},
|
|
507
740
|
submitted_by_me: {
|
|
508
741
|
label: "\u81EA\u5206\u304C\u9001\u4FE1"
|
|
@@ -513,6 +746,92 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
513
746
|
all_requests: {
|
|
514
747
|
label: "\u3059\u3079\u3066"
|
|
515
748
|
}
|
|
749
|
+
},
|
|
750
|
+
_actions: {
|
|
751
|
+
approval_approve: {
|
|
752
|
+
label: "\u627F\u8A8D",
|
|
753
|
+
successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
|
|
754
|
+
params: {
|
|
755
|
+
comment: {
|
|
756
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
757
|
+
},
|
|
758
|
+
attachments: {
|
|
759
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
approval_reject: {
|
|
764
|
+
label: "\u5374\u4E0B",
|
|
765
|
+
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",
|
|
766
|
+
successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
|
|
767
|
+
params: {
|
|
768
|
+
comment: {
|
|
769
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
770
|
+
},
|
|
771
|
+
attachments: {
|
|
772
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
},
|
|
776
|
+
approval_reassign: {
|
|
777
|
+
label: "\u518D\u5272\u308A\u5F53\u3066",
|
|
778
|
+
successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
|
|
779
|
+
params: {
|
|
780
|
+
to: {
|
|
781
|
+
label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
|
|
782
|
+
helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
|
|
783
|
+
},
|
|
784
|
+
comment: {
|
|
785
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
},
|
|
789
|
+
approval_send_back: {
|
|
790
|
+
label: "\u5DEE\u3057\u623B\u3057",
|
|
791
|
+
successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
|
|
792
|
+
params: {
|
|
793
|
+
comment: {
|
|
794
|
+
label: "\u7406\u7531"
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
},
|
|
798
|
+
approval_request_info: {
|
|
799
|
+
label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
|
|
800
|
+
successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
|
|
801
|
+
params: {
|
|
802
|
+
comment: {
|
|
803
|
+
label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
approval_remind: {
|
|
808
|
+
label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
|
|
809
|
+
successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
|
|
810
|
+
params: {
|
|
811
|
+
comment: {
|
|
812
|
+
label: "\u30E1\u30E2"
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
},
|
|
816
|
+
approval_recall: {
|
|
817
|
+
label: "\u53D6\u308A\u4E0B\u3052",
|
|
818
|
+
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",
|
|
819
|
+
successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
|
|
820
|
+
params: {
|
|
821
|
+
comment: {
|
|
822
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
approval_resubmit: {
|
|
827
|
+
label: "\u518D\u63D0\u51FA",
|
|
828
|
+
successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
|
|
829
|
+
params: {
|
|
830
|
+
comment: {
|
|
831
|
+
label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
516
835
|
}
|
|
517
836
|
},
|
|
518
837
|
sys_approval_action: {
|
|
@@ -544,7 +863,14 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
544
863
|
approve: "\u627F\u8A8D",
|
|
545
864
|
reject: "\u5374\u4E0B",
|
|
546
865
|
recall: "\u53D6\u6D88",
|
|
547
|
-
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
|
|
866
|
+
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
|
|
867
|
+
reassign: "\u518D\u5272\u308A\u5F53\u3066",
|
|
868
|
+
remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
|
|
869
|
+
request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
|
|
870
|
+
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
871
|
+
revise: "\u5DEE\u3057\u623B\u3057",
|
|
872
|
+
resubmit: "\u518D\u63D0\u51FA",
|
|
873
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
548
874
|
}
|
|
549
875
|
},
|
|
550
876
|
actor_id: {
|
|
@@ -553,13 +879,29 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
553
879
|
comment: {
|
|
554
880
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
555
881
|
},
|
|
882
|
+
reassign_from: {
|
|
883
|
+
label: "\u5F15\u304D\u7D99\u304E\u5143",
|
|
884
|
+
help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
|
|
885
|
+
},
|
|
886
|
+
reassign_to: {
|
|
887
|
+
label: "\u5F15\u304D\u7D99\u304E\u5148",
|
|
888
|
+
help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u53D7\u3051\u53D6\u3063\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
|
|
889
|
+
},
|
|
890
|
+
attachments: {
|
|
891
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
|
|
892
|
+
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"
|
|
893
|
+
},
|
|
556
894
|
created_at: {
|
|
557
895
|
label: "\u4F5C\u6210\u65E5\u6642"
|
|
558
896
|
}
|
|
559
897
|
},
|
|
560
898
|
_views: {
|
|
561
899
|
recent: {
|
|
562
|
-
label: "\u6700\u8FD1"
|
|
900
|
+
label: "\u6700\u8FD1",
|
|
901
|
+
emptyState: {
|
|
902
|
+
title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
|
|
903
|
+
message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
|
|
904
|
+
}
|
|
563
905
|
},
|
|
564
906
|
by_actor: {
|
|
565
907
|
label: "\u64CD\u4F5C\u8005\u5225"
|
|
@@ -663,7 +1005,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
663
1005
|
pending: "Pendiente",
|
|
664
1006
|
approved: "Aprobada",
|
|
665
1007
|
rejected: "Rechazada",
|
|
666
|
-
recalled: "Retirada"
|
|
1008
|
+
recalled: "Retirada",
|
|
1009
|
+
returned: "Devuelta para revisi\xF3n"
|
|
667
1010
|
}
|
|
668
1011
|
},
|
|
669
1012
|
current_step: {
|
|
@@ -705,7 +1048,11 @@ var init_es_ES_objects_generated = __esm({
|
|
|
705
1048
|
},
|
|
706
1049
|
_views: {
|
|
707
1050
|
my_pending: {
|
|
708
|
-
label: "Mis pendientes"
|
|
1051
|
+
label: "Mis pendientes",
|
|
1052
|
+
emptyState: {
|
|
1053
|
+
title: "Sin aprobaciones pendientes",
|
|
1054
|
+
message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
|
|
1055
|
+
}
|
|
709
1056
|
},
|
|
710
1057
|
submitted_by_me: {
|
|
711
1058
|
label: "Enviadas por m\xED"
|
|
@@ -716,6 +1063,92 @@ var init_es_ES_objects_generated = __esm({
|
|
|
716
1063
|
all_requests: {
|
|
717
1064
|
label: "Todas"
|
|
718
1065
|
}
|
|
1066
|
+
},
|
|
1067
|
+
_actions: {
|
|
1068
|
+
approval_approve: {
|
|
1069
|
+
label: "Aprobar",
|
|
1070
|
+
successMessage: "Aprobada.",
|
|
1071
|
+
params: {
|
|
1072
|
+
comment: {
|
|
1073
|
+
label: "Comentario"
|
|
1074
|
+
},
|
|
1075
|
+
attachments: {
|
|
1076
|
+
label: "Adjuntos"
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
},
|
|
1080
|
+
approval_reject: {
|
|
1081
|
+
label: "Rechazar",
|
|
1082
|
+
confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
|
|
1083
|
+
successMessage: "Rechazada.",
|
|
1084
|
+
params: {
|
|
1085
|
+
comment: {
|
|
1086
|
+
label: "Comentario"
|
|
1087
|
+
},
|
|
1088
|
+
attachments: {
|
|
1089
|
+
label: "Adjuntos"
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
approval_reassign: {
|
|
1094
|
+
label: "Reasignar",
|
|
1095
|
+
successMessage: "Reasignada.",
|
|
1096
|
+
params: {
|
|
1097
|
+
to: {
|
|
1098
|
+
label: "Nuevo aprobador",
|
|
1099
|
+
helpText: "Usuario al que se entrega este paso"
|
|
1100
|
+
},
|
|
1101
|
+
comment: {
|
|
1102
|
+
label: "Comentario"
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
},
|
|
1106
|
+
approval_send_back: {
|
|
1107
|
+
label: "Devolver",
|
|
1108
|
+
successMessage: "Devuelta para revisi\xF3n.",
|
|
1109
|
+
params: {
|
|
1110
|
+
comment: {
|
|
1111
|
+
label: "Motivo"
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
},
|
|
1115
|
+
approval_request_info: {
|
|
1116
|
+
label: "Pedir informaci\xF3n",
|
|
1117
|
+
successMessage: "Informaci\xF3n solicitada.",
|
|
1118
|
+
params: {
|
|
1119
|
+
comment: {
|
|
1120
|
+
label: "\xBFQu\xE9 necesitas?"
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
},
|
|
1124
|
+
approval_remind: {
|
|
1125
|
+
label: "Enviar recordatorio",
|
|
1126
|
+
successMessage: "Recordatorio enviado.",
|
|
1127
|
+
params: {
|
|
1128
|
+
comment: {
|
|
1129
|
+
label: "Nota"
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
},
|
|
1133
|
+
approval_recall: {
|
|
1134
|
+
label: "Retirar",
|
|
1135
|
+
confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
|
|
1136
|
+
successMessage: "Retirada.",
|
|
1137
|
+
params: {
|
|
1138
|
+
comment: {
|
|
1139
|
+
label: "Comentario"
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
},
|
|
1143
|
+
approval_resubmit: {
|
|
1144
|
+
label: "Reenviar",
|
|
1145
|
+
successMessage: "Reenviada.",
|
|
1146
|
+
params: {
|
|
1147
|
+
comment: {
|
|
1148
|
+
label: "\xBFQu\xE9 ha cambiado?"
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
719
1152
|
}
|
|
720
1153
|
},
|
|
721
1154
|
sys_approval_action: {
|
|
@@ -747,7 +1180,14 @@ var init_es_ES_objects_generated = __esm({
|
|
|
747
1180
|
approve: "Aprobar",
|
|
748
1181
|
reject: "Rechazar",
|
|
749
1182
|
recall: "Retirar",
|
|
750
|
-
escalate: "Escalar"
|
|
1183
|
+
escalate: "Escalar",
|
|
1184
|
+
reassign: "Reasignaci\xF3n",
|
|
1185
|
+
remind: "Recordatorio",
|
|
1186
|
+
request_info: "Petici\xF3n de informaci\xF3n",
|
|
1187
|
+
comment: "Comentario",
|
|
1188
|
+
revise: "Devoluci\xF3n",
|
|
1189
|
+
resubmit: "Reenv\xEDo",
|
|
1190
|
+
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
751
1191
|
}
|
|
752
1192
|
},
|
|
753
1193
|
actor_id: {
|
|
@@ -756,13 +1196,29 @@ var init_es_ES_objects_generated = __esm({
|
|
|
756
1196
|
comment: {
|
|
757
1197
|
label: "Comentario"
|
|
758
1198
|
},
|
|
1199
|
+
reassign_from: {
|
|
1200
|
+
label: "Reasignado de",
|
|
1201
|
+
help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
|
|
1202
|
+
},
|
|
1203
|
+
reassign_to: {
|
|
1204
|
+
label: "Reasignado a",
|
|
1205
|
+
help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
|
|
1206
|
+
},
|
|
1207
|
+
attachments: {
|
|
1208
|
+
label: "Adjuntos",
|
|
1209
|
+
help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
|
|
1210
|
+
},
|
|
759
1211
|
created_at: {
|
|
760
1212
|
label: "Creado el"
|
|
761
1213
|
}
|
|
762
1214
|
},
|
|
763
1215
|
_views: {
|
|
764
1216
|
recent: {
|
|
765
|
-
label: "Recientes"
|
|
1217
|
+
label: "Recientes",
|
|
1218
|
+
emptyState: {
|
|
1219
|
+
title: "A\xFAn no hay acciones de aprobaci\xF3n",
|
|
1220
|
+
message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
|
|
1221
|
+
}
|
|
766
1222
|
},
|
|
767
1223
|
by_actor: {
|
|
768
1224
|
label: "Por actor"
|
|
@@ -849,6 +1305,7 @@ var init_translations = __esm({
|
|
|
849
1305
|
|
|
850
1306
|
// src/sys-approval-request.object.ts
|
|
851
1307
|
import { ObjectSchema, Field } from "@objectstack/spec/data";
|
|
1308
|
+
import { APPROVAL_STATUSES } from "@objectstack/spec/contracts";
|
|
852
1309
|
var SysApprovalRequest = ObjectSchema.create({
|
|
853
1310
|
name: "sys_approval_request",
|
|
854
1311
|
label: "Approval Request",
|
|
@@ -947,9 +1404,10 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
947
1404
|
group: "Target"
|
|
948
1405
|
}),
|
|
949
1406
|
status: Field.select(
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
|
|
1407
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
|
|
1408
|
+
// where the list and the reason for each entry live; `ApprovalStatus` is
|
|
1409
|
+
// derived from it, so this column and the contract cannot disagree.
|
|
1410
|
+
[...APPROVAL_STATUSES],
|
|
953
1411
|
{
|
|
954
1412
|
label: "Status",
|
|
955
1413
|
required: true,
|
|
@@ -1047,13 +1505,22 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1047
1505
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1048
1506
|
// (the caller is a current pending approver — same check the service
|
|
1049
1507
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1050
|
-
// submitter actions on `record.viewer.is_submitter`.
|
|
1051
|
-
//
|
|
1508
|
+
// submitter actions on `record.viewer.is_submitter`. The core decision levers
|
|
1509
|
+
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
|
|
1510
|
+
// (#3424) so a platform/tenant admin can rescue a request routed to an
|
|
1511
|
+
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1512
|
+
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1513
|
+
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1514
|
+
// closed.
|
|
1052
1515
|
actions: [
|
|
1053
1516
|
{
|
|
1054
1517
|
name: "approval_approve",
|
|
1055
1518
|
label: "Approve",
|
|
1056
1519
|
icon: "check-circle",
|
|
1520
|
+
// Primary decision — the console renders this filled/highlighted so it
|
|
1521
|
+
// stands out from the secondary levers in the drawer's action bar,
|
|
1522
|
+
// matching the mobile card hierarchy (objectui#2762 P1-5).
|
|
1523
|
+
variant: "primary",
|
|
1057
1524
|
type: "api",
|
|
1058
1525
|
method: "POST",
|
|
1059
1526
|
target: "/api/v1/approvals/requests/{id}/approve",
|
|
@@ -1064,7 +1531,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1064
1531
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1065
1532
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1066
1533
|
],
|
|
1067
|
-
visible: "record.viewer.can_act",
|
|
1534
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1068
1535
|
locations: ["record_section", "list_item"],
|
|
1069
1536
|
successMessage: "Approved.",
|
|
1070
1537
|
refreshAfter: true
|
|
@@ -1073,6 +1540,9 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1073
1540
|
name: "approval_reject",
|
|
1074
1541
|
label: "Reject",
|
|
1075
1542
|
icon: "x-circle",
|
|
1543
|
+
// Destructive decision — rendered in the console's danger styling so it
|
|
1544
|
+
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
1545
|
+
variant: "danger",
|
|
1076
1546
|
type: "api",
|
|
1077
1547
|
method: "POST",
|
|
1078
1548
|
target: "/api/v1/approvals/requests/{id}/reject",
|
|
@@ -1080,7 +1550,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1080
1550
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1081
1551
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1082
1552
|
],
|
|
1083
|
-
visible: "record.viewer.can_act",
|
|
1553
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1084
1554
|
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1085
1555
|
locations: ["record_section", "list_item"],
|
|
1086
1556
|
successMessage: "Rejected.",
|
|
@@ -1102,7 +1572,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1102
1572
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1103
1573
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1104
1574
|
],
|
|
1105
|
-
visible: "record.viewer.can_act",
|
|
1575
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1106
1576
|
locations: ["record_section"],
|
|
1107
1577
|
successMessage: "Reassigned.",
|
|
1108
1578
|
refreshAfter: true
|
|
@@ -1206,6 +1676,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
1206
1676
|
|
|
1207
1677
|
// src/sys-approval-action.object.ts
|
|
1208
1678
|
import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
|
|
1679
|
+
import { APPROVAL_ACTION_KINDS } from "@objectstack/spec/contracts";
|
|
1209
1680
|
var SysApprovalAction = ObjectSchema2.create({
|
|
1210
1681
|
name: "sys_approval_action",
|
|
1211
1682
|
label: "Approval Action",
|
|
@@ -1219,6 +1690,14 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1219
1690
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1220
1691
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1221
1692
|
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1693
|
+
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1694
|
+
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1695
|
+
// their download by testing whether the caller can READ this row. It cannot:
|
|
1696
|
+
// this table is deliberately closed to ordinary approver positions, so that
|
|
1697
|
+
// test denies the very approver the attachment was filed for. The approvals
|
|
1698
|
+
// service already owns the rule for seeing a decision (visibility of the
|
|
1699
|
+
// parent request, exactly as `listActions` applies it), so it answers.
|
|
1700
|
+
fileAccessDelegate: "approvals",
|
|
1222
1701
|
listViews: {
|
|
1223
1702
|
recent: {
|
|
1224
1703
|
type: "grid",
|
|
@@ -1276,12 +1755,11 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1276
1755
|
group: "Target"
|
|
1277
1756
|
}),
|
|
1278
1757
|
action: Field2.select(
|
|
1279
|
-
//
|
|
1280
|
-
//
|
|
1281
|
-
//
|
|
1282
|
-
//
|
|
1283
|
-
|
|
1284
|
-
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
1758
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
|
|
1759
|
+
// is where the list and the per-kind notes live (which kinds move the flow
|
|
1760
|
+
// and which are thread-only); `ApprovalActionKind` is derived from it, so
|
|
1761
|
+
// this column and the contract cannot disagree.
|
|
1762
|
+
[...APPROVAL_ACTION_KINDS],
|
|
1285
1763
|
{
|
|
1286
1764
|
label: "Action",
|
|
1287
1765
|
required: true,
|
|
@@ -1294,6 +1772,23 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
1294
1772
|
group: "Action"
|
|
1295
1773
|
}),
|
|
1296
1774
|
comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1775
|
+
// Structured hand-off parties for `action: 'reassign'` (#4365). Before
|
|
1776
|
+
// these existed the pair lived only inside a default free-text comment
|
|
1777
|
+
// ("<from_id> → <to_id>"), which no client could parse or render readably.
|
|
1778
|
+
// `comment` is pure user input again; timelines render "from A to B" from
|
|
1779
|
+
// these fields.
|
|
1780
|
+
reassign_from: Field2.lookup("sys_user", {
|
|
1781
|
+
label: "Reassigned From",
|
|
1782
|
+
required: false,
|
|
1783
|
+
group: "Action",
|
|
1784
|
+
description: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
1785
|
+
}),
|
|
1786
|
+
reassign_to: Field2.lookup("sys_user", {
|
|
1787
|
+
label: "Reassigned To",
|
|
1788
|
+
required: false,
|
|
1789
|
+
group: "Action",
|
|
1790
|
+
description: "User who received the pending-approver slot (reassign actions only)"
|
|
1791
|
+
}),
|
|
1297
1792
|
attachments: Field2.file({
|
|
1298
1793
|
label: "Attachments",
|
|
1299
1794
|
required: false,
|
|
@@ -1470,9 +1965,9 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
1470
1965
|
trackHistory: true,
|
|
1471
1966
|
searchable: true,
|
|
1472
1967
|
apiEnabled: true,
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1968
|
+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
|
|
1969
|
+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
|
|
1970
|
+
apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
|
|
1476
1971
|
}
|
|
1477
1972
|
});
|
|
1478
1973
|
|
|
@@ -1480,16 +1975,168 @@ var SysApprovalDelegation = ObjectSchema4.create({
|
|
|
1480
1975
|
import { createHash, randomBytes } from "crypto";
|
|
1481
1976
|
import {
|
|
1482
1977
|
APPROVAL_BRANCH_LABELS,
|
|
1483
|
-
|
|
1978
|
+
approverTypeIsOrgScoped,
|
|
1979
|
+
canonicalApproverType,
|
|
1980
|
+
normalizeDecisionOutputs
|
|
1484
1981
|
} from "@objectstack/spec/automation";
|
|
1982
|
+
import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
|
|
1983
|
+
import { keysetWalk } from "@objectstack/types";
|
|
1984
|
+
import {
|
|
1985
|
+
ADMIN_FULL_ACCESS,
|
|
1986
|
+
ORGANIZATION_ADMIN_GRANTS,
|
|
1987
|
+
BUILTIN_IDENTITY_PLATFORM_ADMIN,
|
|
1988
|
+
BUILTIN_IDENTITY_ORG_OWNER,
|
|
1989
|
+
BUILTIN_IDENTITY_ORG_ADMIN
|
|
1990
|
+
} from "@objectstack/spec/identity";
|
|
1991
|
+
import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
|
|
1992
|
+
import { isFileIdToken } from "@objectstack/spec/data";
|
|
1485
1993
|
import { isGrantActive } from "@objectstack/core";
|
|
1994
|
+
|
|
1995
|
+
// src/approver-org-scope.ts
|
|
1996
|
+
var MAX_ORG_DEPTH = 32;
|
|
1997
|
+
var SYSTEM_CTX = { isSystem: true };
|
|
1998
|
+
function fail(message) {
|
|
1999
|
+
throw new Error(`VALIDATION_FAILED: ${message}`);
|
|
2000
|
+
}
|
|
2001
|
+
async function findOrg(engine, where) {
|
|
2002
|
+
try {
|
|
2003
|
+
const rows = await engine.find("sys_organization", {
|
|
2004
|
+
where,
|
|
2005
|
+
fields: ["id", "slug", "parent_organization_id"],
|
|
2006
|
+
limit: 1,
|
|
2007
|
+
context: SYSTEM_CTX
|
|
2008
|
+
});
|
|
2009
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
2010
|
+
} catch {
|
|
2011
|
+
return null;
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
async function ancestorChain(engine, orgId) {
|
|
2015
|
+
const chain = [];
|
|
2016
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2017
|
+
let cursor = orgId;
|
|
2018
|
+
for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
|
|
2019
|
+
if (seen.has(cursor)) break;
|
|
2020
|
+
seen.add(cursor);
|
|
2021
|
+
chain.push(cursor);
|
|
2022
|
+
const row = await findOrg(engine, { id: cursor });
|
|
2023
|
+
const parent = row?.parent_organization_id;
|
|
2024
|
+
cursor = parent ? String(parent) : null;
|
|
2025
|
+
}
|
|
2026
|
+
return chain;
|
|
2027
|
+
}
|
|
2028
|
+
async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
|
|
2029
|
+
const declared = typeof declaration === "string" ? declaration.trim() : "";
|
|
2030
|
+
if (!declared) return requestOrgId;
|
|
2031
|
+
if (!isOrgScopedType) {
|
|
2032
|
+
fail(
|
|
2033
|
+
`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)`
|
|
2034
|
+
);
|
|
2035
|
+
}
|
|
2036
|
+
const posture = deps.posture?.();
|
|
2037
|
+
if (posture && posture !== "group") {
|
|
2038
|
+
fail(
|
|
2039
|
+
`cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
|
|
2040
|
+
);
|
|
2041
|
+
}
|
|
2042
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2043
|
+
if (!requestOrg) {
|
|
2044
|
+
fail(
|
|
2045
|
+
`'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
const chain = await ancestorChain(deps.engine, requestOrg);
|
|
2049
|
+
if (declared === "$root") {
|
|
2050
|
+
const root = chain[chain.length - 1];
|
|
2051
|
+
if (root === requestOrg && chain.length === 1) {
|
|
2052
|
+
fail(
|
|
2053
|
+
`'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`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
return root;
|
|
2057
|
+
}
|
|
2058
|
+
if (declared === "$parent") {
|
|
2059
|
+
const parent = chain[1];
|
|
2060
|
+
if (!parent) {
|
|
2061
|
+
fail(
|
|
2062
|
+
`'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')`
|
|
2063
|
+
);
|
|
2064
|
+
}
|
|
2065
|
+
return parent;
|
|
2066
|
+
}
|
|
2067
|
+
const target = await findOrg(deps.engine, { slug: declared });
|
|
2068
|
+
if (!target?.id) {
|
|
2069
|
+
fail(
|
|
2070
|
+
`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)`
|
|
2071
|
+
);
|
|
2072
|
+
}
|
|
2073
|
+
const targetId = String(target.id);
|
|
2074
|
+
if (targetId === requestOrg) return targetId;
|
|
2075
|
+
const targetChain = await ancestorChain(deps.engine, targetId);
|
|
2076
|
+
const targetRoot = targetChain[targetChain.length - 1];
|
|
2077
|
+
const requestRoot = chain[chain.length - 1];
|
|
2078
|
+
if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
|
|
2079
|
+
fail(
|
|
2080
|
+
`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)`
|
|
2081
|
+
);
|
|
2082
|
+
}
|
|
2083
|
+
return targetId;
|
|
2084
|
+
}
|
|
2085
|
+
async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
2086
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2087
|
+
if (!requestOrg || userIds.length === 0) return userIds;
|
|
2088
|
+
let members = [];
|
|
2089
|
+
try {
|
|
2090
|
+
members = await deps.engine.find("sys_member", {
|
|
2091
|
+
where: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
2092
|
+
fields: ["user_id"],
|
|
2093
|
+
limit: 1e4,
|
|
2094
|
+
context: SYSTEM_CTX
|
|
2095
|
+
});
|
|
2096
|
+
} catch {
|
|
2097
|
+
return userIds;
|
|
2098
|
+
}
|
|
2099
|
+
const canRead = new Set(
|
|
2100
|
+
(members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
|
|
2101
|
+
);
|
|
2102
|
+
const dropped = userIds.filter((u) => !canRead.has(u));
|
|
2103
|
+
if (dropped.length === 0) return userIds;
|
|
2104
|
+
deps.logger?.warn?.(
|
|
2105
|
+
`[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.`,
|
|
2106
|
+
{ dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
|
|
2107
|
+
);
|
|
2108
|
+
return userIds.filter((u) => canRead.has(u));
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// src/approval-service.ts
|
|
1486
2112
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1487
2113
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1488
2114
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1489
2115
|
var SLA_ACTOR_ID = "system:sla";
|
|
2116
|
+
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2117
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2118
|
+
"completed",
|
|
2119
|
+
"failed",
|
|
2120
|
+
"cancelled",
|
|
2121
|
+
"timed_out"
|
|
2122
|
+
]);
|
|
1490
2123
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1491
|
-
var
|
|
2124
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2125
|
+
function actingUserId(context) {
|
|
2126
|
+
const userId = context?.userId;
|
|
2127
|
+
return typeof userId === "string" && userId ? userId : null;
|
|
2128
|
+
}
|
|
1492
2129
|
var OOO_MAX_CHAIN = 8;
|
|
2130
|
+
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2131
|
+
"team",
|
|
2132
|
+
"department",
|
|
2133
|
+
"business_unit",
|
|
2134
|
+
"bu",
|
|
2135
|
+
"position",
|
|
2136
|
+
"org_membership_level",
|
|
2137
|
+
"manager"
|
|
2138
|
+
]);
|
|
2139
|
+
var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
1493
2140
|
function uid(prefix) {
|
|
1494
2141
|
const g = globalThis;
|
|
1495
2142
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1511,6 +2158,12 @@ function csvSplit(raw) {
|
|
|
1511
2158
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
1512
2159
|
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
1513
2160
|
}
|
|
2161
|
+
function isBlankDecisionOutput(value) {
|
|
2162
|
+
if (value === void 0 || value === null) return true;
|
|
2163
|
+
if (typeof value === "string") return value.trim() === "";
|
|
2164
|
+
if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
|
|
2165
|
+
return false;
|
|
2166
|
+
}
|
|
1514
2167
|
function prettifyMachineName(raw) {
|
|
1515
2168
|
if (!raw) return void 0;
|
|
1516
2169
|
const base = String(raw).replace(/^flow:/, "").trim();
|
|
@@ -1543,7 +2196,26 @@ function rowFromRequest(row) {
|
|
|
1543
2196
|
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
|
|
1544
2197
|
sla_due_at: slaDueAt(row.created_at, cfg),
|
|
1545
2198
|
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
|
|
1546
|
-
round: typeof cfg?.__round === "number" ? cfg.__round : void 0
|
|
2199
|
+
round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
|
|
2200
|
+
// objectui#2902: the node's record-lock policy. The lock is enforced
|
|
2201
|
+
// server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
|
|
2202
|
+
// same `!== false` default, so the flag a client renders and the rule the
|
|
2203
|
+
// server applies can never drift. Without it a console can only see
|
|
2204
|
+
// "a pending request exists" and has to assume the record is locked —
|
|
2205
|
+
// which mislabels every `lockRecord: false` node as locked and hides an
|
|
2206
|
+
// edit the server would have accepted.
|
|
2207
|
+
lock_record: cfg?.lockRecord !== false,
|
|
2208
|
+
// #3447 P2: the node's author-declared decision outputs, surfaced so a
|
|
2209
|
+
// decision UI can render input fields for them and POST `outputs` on
|
|
2210
|
+
// approve/reject. Per-request (each node declares its own), which is why
|
|
2211
|
+
// this rides the row instead of the static action params. Two shapes for
|
|
2212
|
+
// version skew: `decision_outputs` stays the bare KEY list an older
|
|
2213
|
+
// console renders as text inputs; `decision_output_defs` carries the
|
|
2214
|
+
// normalized typed declarations a picker-aware console prefers.
|
|
2215
|
+
...(() => {
|
|
2216
|
+
const defs = normalizeDecisionOutputs(cfg?.decisionOutputs);
|
|
2217
|
+
return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
|
|
2218
|
+
})()
|
|
1547
2219
|
};
|
|
1548
2220
|
}
|
|
1549
2221
|
function slaDueAt(createdAt, cfg) {
|
|
@@ -1553,7 +2225,29 @@ function slaDueAt(createdAt, cfg) {
|
|
|
1553
2225
|
if (Number.isNaN(t)) return void 0;
|
|
1554
2226
|
return new Date(t + hours * 36e5).toISOString();
|
|
1555
2227
|
}
|
|
2228
|
+
function normalizeActionAttachment(entry) {
|
|
2229
|
+
if (entry == null) return void 0;
|
|
2230
|
+
if (typeof entry === "string") {
|
|
2231
|
+
const id = entry.trim();
|
|
2232
|
+
if (!id) return void 0;
|
|
2233
|
+
return isFileIdToken(id) ? { id } : { id, url: id };
|
|
2234
|
+
}
|
|
2235
|
+
if (typeof entry === "object") {
|
|
2236
|
+
const id = entry.id ?? entry.file_id;
|
|
2237
|
+
if (id == null || String(id) === "") return void 0;
|
|
2238
|
+
const mimeType = entry.mimeType ?? entry.mime_type;
|
|
2239
|
+
return {
|
|
2240
|
+
id: String(id),
|
|
2241
|
+
name: typeof entry.name === "string" ? entry.name : void 0,
|
|
2242
|
+
url: typeof entry.url === "string" ? entry.url : void 0,
|
|
2243
|
+
mimeType: typeof mimeType === "string" ? mimeType : void 0,
|
|
2244
|
+
size: typeof entry.size === "number" ? entry.size : void 0
|
|
2245
|
+
};
|
|
2246
|
+
}
|
|
2247
|
+
return void 0;
|
|
2248
|
+
}
|
|
1556
2249
|
function rowFromAction(row) {
|
|
2250
|
+
const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
|
|
1557
2251
|
return {
|
|
1558
2252
|
id: String(row.id),
|
|
1559
2253
|
request_id: String(row.request_id),
|
|
@@ -1562,10 +2256,12 @@ function rowFromAction(row) {
|
|
|
1562
2256
|
action: row.action,
|
|
1563
2257
|
actor_id: row.actor_id ?? void 0,
|
|
1564
2258
|
comment: row.comment ?? void 0,
|
|
1565
|
-
//
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
attachments
|
|
2259
|
+
// Structured reassign hand-off parties (#4365).
|
|
2260
|
+
reassign_from: row.reassign_from ?? void 0,
|
|
2261
|
+
reassign_to: row.reassign_to ?? void 0,
|
|
2262
|
+
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2263
|
+
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2264
|
+
attachments: attachments.length ? attachments : void 0,
|
|
1569
2265
|
created_at: row.created_at ?? void 0
|
|
1570
2266
|
};
|
|
1571
2267
|
}
|
|
@@ -1577,6 +2273,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1577
2273
|
this.automation = opts.automation;
|
|
1578
2274
|
this.messaging = opts.messaging;
|
|
1579
2275
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2276
|
+
this.tenancyPosture = opts.tenancyPosture;
|
|
2277
|
+
}
|
|
2278
|
+
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2279
|
+
attachTenancyPosture(provider) {
|
|
2280
|
+
this.tenancyPosture = provider;
|
|
2281
|
+
}
|
|
2282
|
+
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2283
|
+
get orgScopeDeps() {
|
|
2284
|
+
return {
|
|
2285
|
+
engine: this.engine,
|
|
2286
|
+
posture: this.tenancyPosture,
|
|
2287
|
+
logger: this.logger
|
|
2288
|
+
};
|
|
2289
|
+
}
|
|
2290
|
+
/**
|
|
2291
|
+
* [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
|
|
2292
|
+
* Absent declaration ⇒ the request's own organization (unchanged, no reads).
|
|
2293
|
+
*/
|
|
2294
|
+
async directoryOrgFor(a, requestOrgId) {
|
|
2295
|
+
const rawType = String(a?.type ?? "");
|
|
2296
|
+
return resolveApproverDirectoryOrg(
|
|
2297
|
+
this.orgScopeDeps,
|
|
2298
|
+
a?.organization,
|
|
2299
|
+
requestOrgId,
|
|
2300
|
+
rawType,
|
|
2301
|
+
approverTypeIsOrgScoped(rawType)
|
|
2302
|
+
);
|
|
1580
2303
|
}
|
|
1581
2304
|
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
1582
2305
|
attachAutomation(automation) {
|
|
@@ -1611,13 +2334,108 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1611
2334
|
const rows = await this.engine.find("sys_approval_request", {
|
|
1612
2335
|
where: { id: requestId },
|
|
1613
2336
|
limit: 1,
|
|
1614
|
-
context:
|
|
2337
|
+
context: SYSTEM_CTX2
|
|
1615
2338
|
});
|
|
1616
2339
|
const raw = Array.isArray(rows) ? rows[0] : null;
|
|
1617
2340
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
1618
2341
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
1619
2342
|
return raw;
|
|
1620
2343
|
}
|
|
2344
|
+
/**
|
|
2345
|
+
* Privileged-override gate (#3424). A stuck approval — one routed to a
|
|
2346
|
+
* position/team with no holders (so its `pending_approvers` is only an
|
|
2347
|
+
* unresolvable `type:value` literal) or to approvers who have all since left —
|
|
2348
|
+
* is otherwise undecidable: no concrete user is in the slate, so every normal
|
|
2349
|
+
* `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
|
|
2350
|
+
* record stays locked forever with no in-product recovery. A platform or
|
|
2351
|
+
* tenant admin — the same posture the engine's superuser bypass already
|
|
2352
|
+
* trusts — may always act on a PENDING request to release it: approve, reject,
|
|
2353
|
+
* reassign it to a real approver, or recall it.
|
|
2354
|
+
*
|
|
2355
|
+
* A platform admin crosses the tenant wall (matching the unscoped
|
|
2356
|
+
* `admin_full_access` evidence); a tenant admin may override only within their
|
|
2357
|
+
* own org (or an org-less request). A system context always passes. Signals are
|
|
2358
|
+
* read defensively off the resolved exec context (`permissions` / `positions` /
|
|
2359
|
+
* the derived `posture`, ADR-0095) so any transport that resolves through the
|
|
2360
|
+
* shared authz resolver lights this up without extra wiring.
|
|
2361
|
+
*/
|
|
2362
|
+
isOverrideActor(context, requestOrg) {
|
|
2363
|
+
if (!context) return false;
|
|
2364
|
+
if (context.isSystem) return true;
|
|
2365
|
+
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
2366
|
+
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
2367
|
+
const posture = context.posture;
|
|
2368
|
+
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS) || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
2369
|
+
if (isPlatformAdmin) return true;
|
|
2370
|
+
const isTenantAdmin = posture === "TENANT_ADMIN" || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
|
|
2371
|
+
if (!isTenantAdmin) return false;
|
|
2372
|
+
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2373
|
+
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2374
|
+
}
|
|
2375
|
+
/**
|
|
2376
|
+
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2377
|
+
*
|
|
2378
|
+
* Every public entrypoint accepts an `actorId`, and the REST routes fill it
|
|
2379
|
+
* from `body.actorId ?? body.actor_id ?? context.userId` — so before this
|
|
2380
|
+
* gate the body won. The authorization checks downstream all read that value
|
|
2381
|
+
* (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
|
|
2382
|
+
* which made the body-supplied string not merely the audit label but the key
|
|
2383
|
+
* that opens the door: any authenticated user could name a pending approver
|
|
2384
|
+
* and have that approver's decision recorded and the owning flow resumed.
|
|
2385
|
+
* #3783 drew this line for the data-write identity ({@link actingUserId});
|
|
2386
|
+
* this closes the authorization half.
|
|
2387
|
+
*
|
|
2388
|
+
* A caller may still name an identity OTHER than their bare user id, because
|
|
2389
|
+
* a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
|
|
2390
|
+
* `type:value` literal when a graph lookup yields nothing, and an author may
|
|
2391
|
+
* write an email as a `user` approver. So the rule is not "actorId must equal
|
|
2392
|
+
* userId" — it is **"actorId must be an identity the SERVER can prove belongs
|
|
2393
|
+
* to the caller"**. Anything else is `FORBIDDEN`.
|
|
2394
|
+
*
|
|
2395
|
+
* A system context is exempt and keeps its explicit actor: the SLA sweep
|
|
2396
|
+
* passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
|
|
2397
|
+
* link passes the approver its single-use token is cryptographically bound to
|
|
2398
|
+
* (having also put them on the context). Those are the only two callers that
|
|
2399
|
+
* hold a trustworthy actor with no session behind them.
|
|
2400
|
+
*
|
|
2401
|
+
* A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
|
|
2402
|
+
* anonymous-deny now denies every anonymous request (#3963), but this service
|
|
2403
|
+
* must not rely on a caller upstream — an anonymous actor could otherwise
|
|
2404
|
+
* decide approvals outright by naming one.
|
|
2405
|
+
*/
|
|
2406
|
+
async resolveActor(actorId, context) {
|
|
2407
|
+
if (context?.isSystem) {
|
|
2408
|
+
if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
2409
|
+
return actorId;
|
|
2410
|
+
}
|
|
2411
|
+
const uid2 = actingUserId(context);
|
|
2412
|
+
if (!uid2) {
|
|
2413
|
+
throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
|
|
2414
|
+
}
|
|
2415
|
+
if (!actorId || String(actorId) === uid2) return uid2;
|
|
2416
|
+
const named = String(actorId);
|
|
2417
|
+
for (const position of context.positions ?? []) {
|
|
2418
|
+
if (named === `position:${position}` || named === `role:${position}`) return named;
|
|
2419
|
+
}
|
|
2420
|
+
if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
|
|
2421
|
+
throw new Error(
|
|
2422
|
+
`FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
|
|
2423
|
+
);
|
|
2424
|
+
}
|
|
2425
|
+
/** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
|
|
2426
|
+
async callerHasEmail(userId, email) {
|
|
2427
|
+
try {
|
|
2428
|
+
const rows = await this.engine.find("sys_user", {
|
|
2429
|
+
where: { id: userId },
|
|
2430
|
+
limit: 1,
|
|
2431
|
+
context: SYSTEM_CTX2
|
|
2432
|
+
});
|
|
2433
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2434
|
+
return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
|
|
2435
|
+
} catch {
|
|
2436
|
+
return false;
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
1621
2439
|
/**
|
|
1622
2440
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1623
2441
|
* graph tables for `team:` / `department:` / `position:` /
|
|
@@ -1652,7 +2470,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1652
2470
|
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1653
2471
|
*/
|
|
1654
2472
|
async expandApprovers(step, record, organizationId, opts) {
|
|
1655
|
-
var _a;
|
|
2473
|
+
var _a, _b, _c;
|
|
1656
2474
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1657
2475
|
const now = opts?.now ?? this.clock.now().getTime();
|
|
1658
2476
|
const out = [];
|
|
@@ -1660,12 +2478,34 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1660
2478
|
for (let idx = 0; idx < specs.length; idx++) {
|
|
1661
2479
|
const a = specs[idx];
|
|
1662
2480
|
if (!a) continue;
|
|
1663
|
-
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1664
2481
|
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
2482
|
+
if (canonicalApproverType(String(a.type)) === "expression") {
|
|
2483
|
+
const resolved = await this.resolveExpressionApprovers(
|
|
2484
|
+
a,
|
|
2485
|
+
record,
|
|
2486
|
+
organizationId,
|
|
2487
|
+
now,
|
|
2488
|
+
opts?.substitutions,
|
|
2489
|
+
opts?.exprCtx
|
|
2490
|
+
);
|
|
2491
|
+
if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
|
|
2492
|
+
for (const entry of resolved.slots) {
|
|
2493
|
+
if (!entry.id) continue;
|
|
2494
|
+
out.push(entry.id);
|
|
2495
|
+
if (opts?.groups) {
|
|
2496
|
+
((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
|
|
2497
|
+
}
|
|
2498
|
+
}
|
|
2499
|
+
continue;
|
|
2500
|
+
}
|
|
2501
|
+
if (opts?.resolvedFrom && canonicalApproverType(String(a.type)) === "field" && a.value != null) {
|
|
2502
|
+
opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
|
|
2503
|
+
}
|
|
2504
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1665
2505
|
for (const u of ids) {
|
|
1666
2506
|
if (!u) continue;
|
|
1667
2507
|
out.push(u);
|
|
1668
|
-
if (opts?.groups) ((
|
|
2508
|
+
if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
|
|
1669
2509
|
}
|
|
1670
2510
|
}
|
|
1671
2511
|
return out.filter(Boolean);
|
|
@@ -1686,24 +2526,35 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1686
2526
|
{ deprecated: a.type, canonical: type }
|
|
1687
2527
|
);
|
|
1688
2528
|
}
|
|
2529
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2530
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
1689
2531
|
if (type === "user") {
|
|
1690
2532
|
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1691
2533
|
}
|
|
1692
2534
|
if (type === "field" && record) {
|
|
1693
|
-
|
|
2535
|
+
const out = [];
|
|
2536
|
+
for (const id of csvSplit(record[a.value])) {
|
|
2537
|
+
out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
|
|
2538
|
+
}
|
|
2539
|
+
return out;
|
|
1694
2540
|
}
|
|
2541
|
+
const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2542
|
+
approverType: type,
|
|
2543
|
+
value: a.value != null ? String(a.value) : void 0,
|
|
2544
|
+
directoryOrgId: directoryOrg
|
|
2545
|
+
}) : users;
|
|
1695
2546
|
try {
|
|
1696
2547
|
if (type === "team") {
|
|
1697
2548
|
const users = await this.expandTeamUsers(String(a.value));
|
|
1698
2549
|
if (users.length) return users;
|
|
1699
2550
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1700
|
-
const users = await this.expandBusinessUnitUsers(String(a.value),
|
|
2551
|
+
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
1701
2552
|
if (users.length) return users;
|
|
1702
2553
|
} else if (type === "position") {
|
|
1703
|
-
const users = await this.expandPositionUsers(String(a.value),
|
|
2554
|
+
const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
|
|
1704
2555
|
if (users.length) return users;
|
|
1705
2556
|
} else if (type === "org_membership_level") {
|
|
1706
|
-
const users = await this.expandMembershipTierUsers(String(a.value),
|
|
2557
|
+
const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
|
|
1707
2558
|
if (users.length) return users;
|
|
1708
2559
|
} else if (type === "manager" && record) {
|
|
1709
2560
|
const subject = record[a.value] ?? record.owner_id;
|
|
@@ -1714,33 +2565,171 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1714
2565
|
}
|
|
1715
2566
|
} catch {
|
|
1716
2567
|
}
|
|
2568
|
+
if (type === "queue") {
|
|
2569
|
+
this.logger?.warn?.(
|
|
2570
|
+
`[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
|
|
2571
|
+
{ value: a.value }
|
|
2572
|
+
);
|
|
2573
|
+
} else if (GRAPH_APPROVER_TYPES.has(type)) {
|
|
2574
|
+
this.logger?.warn?.(
|
|
2575
|
+
`[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)`,
|
|
2576
|
+
{ type, value: a.value, organizationId: organizationId ?? null }
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
1717
2579
|
return [`${a.type}:${a.value}`];
|
|
1718
2580
|
}
|
|
2581
|
+
/**
|
|
2582
|
+
* Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
|
|
2583
|
+
* node entry against the three explicit roots — `current` (live record),
|
|
2584
|
+
* `trigger` (submit snapshot), `vars` (flow variables) — then expand the
|
|
2585
|
+
* result into people per `resolveAs`.
|
|
2586
|
+
*
|
|
2587
|
+
* Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
|
|
2588
|
+
* evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
|
|
2589
|
+
* loudly instead of opening a request routed to nobody — an approver
|
|
2590
|
+
* expression that cannot run is a routing bug, never "condition not met".
|
|
2591
|
+
* Error messages carry the correct spelling because their primary reader is
|
|
2592
|
+
* the AI author fixing the flow on the next validate pass.
|
|
2593
|
+
*
|
|
2594
|
+
* Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
|
|
2595
|
+
* expression's own values, pre-expansion) for the `__resolvedFrom` audit.
|
|
2596
|
+
*/
|
|
2597
|
+
async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
|
|
2598
|
+
const source = String(a.value ?? "").trim();
|
|
2599
|
+
if (!source) {
|
|
2600
|
+
throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
|
|
2601
|
+
}
|
|
2602
|
+
const parsed = collectCelRootIdentifiers(source);
|
|
2603
|
+
if (!parsed.ok) {
|
|
2604
|
+
throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
|
|
2605
|
+
}
|
|
2606
|
+
const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
|
|
2607
|
+
if (illegal.length) {
|
|
2608
|
+
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)?`;
|
|
2609
|
+
throw new Error(
|
|
2610
|
+
`VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
|
|
2611
|
+
);
|
|
2612
|
+
}
|
|
2613
|
+
const result = ExpressionEngine.evaluate(
|
|
2614
|
+
{ dialect: "cel", source },
|
|
2615
|
+
{ extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
|
|
2616
|
+
);
|
|
2617
|
+
if (!result.ok) {
|
|
2618
|
+
throw new Error(
|
|
2619
|
+
`EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
|
|
2620
|
+
);
|
|
2621
|
+
}
|
|
2622
|
+
const value = result.value;
|
|
2623
|
+
let raw;
|
|
2624
|
+
if (value == null || value === "") {
|
|
2625
|
+
raw = [];
|
|
2626
|
+
} else if (typeof value === "string") {
|
|
2627
|
+
raw = csvSplit(value);
|
|
2628
|
+
} else if (Array.isArray(value)) {
|
|
2629
|
+
const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
|
|
2630
|
+
if (bad !== void 0) {
|
|
2631
|
+
throw new Error(
|
|
2632
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
|
|
2633
|
+
);
|
|
2634
|
+
}
|
|
2635
|
+
raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
|
|
2636
|
+
} else {
|
|
2637
|
+
throw new Error(
|
|
2638
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
const resolveAs = String(a.resolveAs ?? "user");
|
|
2642
|
+
if (resolveAs === "user") {
|
|
2643
|
+
const slots2 = [];
|
|
2644
|
+
for (const id of raw) {
|
|
2645
|
+
for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
|
|
2646
|
+
slots2.push({ id: routed });
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
return { slots: slots2, raw };
|
|
2650
|
+
}
|
|
2651
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2652
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
2653
|
+
const slots = [];
|
|
2654
|
+
for (const key of raw) {
|
|
2655
|
+
let users = [];
|
|
2656
|
+
try {
|
|
2657
|
+
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2658
|
+
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2659
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2660
|
+
else {
|
|
2661
|
+
throw new Error(
|
|
2662
|
+
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
2663
|
+
);
|
|
2664
|
+
}
|
|
2665
|
+
} catch (err) {
|
|
2666
|
+
if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
|
|
2667
|
+
users = [];
|
|
2668
|
+
}
|
|
2669
|
+
if (crossOrg && users.length) {
|
|
2670
|
+
users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2671
|
+
approverType: resolveAs,
|
|
2672
|
+
value: key,
|
|
2673
|
+
directoryOrgId: directoryOrg
|
|
2674
|
+
});
|
|
2675
|
+
}
|
|
2676
|
+
if (!users.length) {
|
|
2677
|
+
slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
|
|
2678
|
+
continue;
|
|
2679
|
+
}
|
|
2680
|
+
for (const u of users) slots.push({ id: u, subGroup: key });
|
|
2681
|
+
}
|
|
2682
|
+
return { slots, raw };
|
|
2683
|
+
}
|
|
1719
2684
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1720
2685
|
async expandTeamUsers(teamId) {
|
|
1721
2686
|
if (!teamId) return [];
|
|
1722
2687
|
let rows = [];
|
|
1723
2688
|
try {
|
|
1724
2689
|
rows = await this.engine.find("sys_team_member", {
|
|
1725
|
-
|
|
2690
|
+
where: { team_id: teamId },
|
|
1726
2691
|
fields: ["user_id"],
|
|
1727
2692
|
limit: 1e4,
|
|
1728
|
-
context:
|
|
2693
|
+
context: SYSTEM_CTX2
|
|
1729
2694
|
});
|
|
1730
2695
|
} catch {
|
|
1731
2696
|
rows = [];
|
|
1732
2697
|
}
|
|
1733
2698
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
1734
2699
|
}
|
|
2700
|
+
/**
|
|
2701
|
+
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
2702
|
+
* env-wide (#3807).
|
|
2703
|
+
*
|
|
2704
|
+
* `organization_id = null` on a platform object means "owned by no
|
|
2705
|
+
* organization" — a row written by a seed, the file layer, or bootstrap,
|
|
2706
|
+
* i.e. before (or outside) any org exists. A strict
|
|
2707
|
+
* `organization_id = <request org>` equality made every such row invisible:
|
|
2708
|
+
* the seed check below found nothing, the whole expansion returned `[]`, and
|
|
2709
|
+
* the approver fell back to the dead `department:<id>` literal that routes to
|
|
2710
|
+
* nobody. That is not an edge case — an app's org tree is normally seeded
|
|
2711
|
+
* (a seed cannot know the org id the runtime mints at boot) while the
|
|
2712
|
+
* approval request always carries one, so EVERY department approver a
|
|
2713
|
+
* designer could pick resolved to nobody.
|
|
2714
|
+
*
|
|
2715
|
+
* Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
|
|
2716
|
+
* pending-draft listing settled on for the identical reason. Another org's
|
|
2717
|
+
* unit still fails the match, so the wall between two organizations is
|
|
2718
|
+
* unchanged — only rows belonging to no org at all become visible.
|
|
2719
|
+
*/
|
|
2720
|
+
businessUnitOrgScope(filter, organizationId) {
|
|
2721
|
+
if (!organizationId) return filter;
|
|
2722
|
+
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
2723
|
+
}
|
|
1735
2724
|
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
|
|
1736
2725
|
async expandBusinessUnitUsers(businessUnitId, organizationId) {
|
|
1737
2726
|
if (!businessUnitId) return [];
|
|
1738
2727
|
try {
|
|
1739
2728
|
const seed = await this.engine.find("sys_business_unit", {
|
|
1740
|
-
|
|
2729
|
+
where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
1741
2730
|
fields: ["id", "active"],
|
|
1742
2731
|
limit: 1,
|
|
1743
|
-
context:
|
|
2732
|
+
context: SYSTEM_CTX2
|
|
1744
2733
|
});
|
|
1745
2734
|
const seedRow = Array.isArray(seed) ? seed[0] : null;
|
|
1746
2735
|
if (!seedRow || seedRow.active === false) return [];
|
|
@@ -1753,9 +2742,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1753
2742
|
const parent = queue.shift();
|
|
1754
2743
|
let kids = [];
|
|
1755
2744
|
try {
|
|
1756
|
-
const filter =
|
|
1757
|
-
|
|
1758
|
-
|
|
2745
|
+
const filter = this.businessUnitOrgScope(
|
|
2746
|
+
{ parent_business_unit_id: parent, active: { $ne: false } },
|
|
2747
|
+
organizationId
|
|
2748
|
+
);
|
|
2749
|
+
kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
|
|
1759
2750
|
} catch {
|
|
1760
2751
|
kids = [];
|
|
1761
2752
|
}
|
|
@@ -1770,10 +2761,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1770
2761
|
let rows = [];
|
|
1771
2762
|
try {
|
|
1772
2763
|
rows = await this.engine.find("sys_business_unit_member", {
|
|
1773
|
-
|
|
2764
|
+
where: { business_unit_id: { $in: Array.from(seen) } },
|
|
1774
2765
|
fields: ["user_id"],
|
|
1775
2766
|
limit: 1e4,
|
|
1776
|
-
context:
|
|
2767
|
+
context: SYSTEM_CTX2
|
|
1777
2768
|
});
|
|
1778
2769
|
} catch {
|
|
1779
2770
|
rows = [];
|
|
@@ -1798,7 +2789,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1798
2789
|
filter,
|
|
1799
2790
|
fields: ["user_id"],
|
|
1800
2791
|
limit: 1e4,
|
|
1801
|
-
context:
|
|
2792
|
+
context: SYSTEM_CTX2
|
|
1802
2793
|
});
|
|
1803
2794
|
for (const r of rows ?? []) {
|
|
1804
2795
|
const uid2 = String(r.user_id ?? "");
|
|
@@ -1821,7 +2812,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1821
2812
|
if (organizationId) filter.organization_id = organizationId;
|
|
1822
2813
|
let rows = [];
|
|
1823
2814
|
try {
|
|
1824
|
-
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context:
|
|
2815
|
+
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
|
|
1825
2816
|
} catch {
|
|
1826
2817
|
rows = [];
|
|
1827
2818
|
}
|
|
@@ -1830,10 +2821,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1830
2821
|
async lookupManager(userId) {
|
|
1831
2822
|
try {
|
|
1832
2823
|
const rows = await this.engine.find("sys_user", {
|
|
1833
|
-
|
|
2824
|
+
where: { id: userId },
|
|
1834
2825
|
fields: ["id", "manager_id"],
|
|
1835
2826
|
limit: 1,
|
|
1836
|
-
context:
|
|
2827
|
+
context: SYSTEM_CTX2
|
|
1837
2828
|
});
|
|
1838
2829
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
1839
2830
|
return row?.manager_id ? String(row.manager_id) : null;
|
|
@@ -1880,10 +2871,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1880
2871
|
let rows = [];
|
|
1881
2872
|
try {
|
|
1882
2873
|
rows = await this.engine.find("sys_approval_delegation", {
|
|
1883
|
-
|
|
2874
|
+
where: { delegator_id: delegatorId },
|
|
1884
2875
|
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1885
2876
|
limit: 50,
|
|
1886
|
-
context:
|
|
2877
|
+
context: SYSTEM_CTX2
|
|
1887
2878
|
});
|
|
1888
2879
|
} catch {
|
|
1889
2880
|
return null;
|
|
@@ -1897,14 +2888,86 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1897
2888
|
});
|
|
1898
2889
|
return active[0];
|
|
1899
2890
|
}
|
|
1900
|
-
/**
|
|
1901
|
-
|
|
2891
|
+
/**
|
|
2892
|
+
* Mirror a request status onto a business-object field, if configured.
|
|
2893
|
+
*
|
|
2894
|
+
* **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
|
|
2895
|
+
* record is normally LOCKED while its approval is live and the submitter
|
|
2896
|
+
* cannot edit it, so only a platform write can land the status — that is what
|
|
2897
|
+
* the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
|
|
2898
|
+
* NOT do is throw away *who* caused the transition. Every status below is
|
|
2899
|
+
* something a specific human just did — a submitter submitting or recalling,
|
|
2900
|
+
* an approver deciding or sending back — and this write is what fires the
|
|
2901
|
+
* target object's record-change flows. With no `userId` on it those cascades
|
|
2902
|
+
* inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
|
|
2903
|
+
* user has its data ops REFUSED — so "when the invoice is approved, do X", the
|
|
2904
|
+
* most natural approvals automation there is, had to declare `runAs:'system'`
|
|
2905
|
+
* and take blanket elevation for a case where a perfectly good scoped identity
|
|
2906
|
+
* existed. Re-attaching the actor lets those cascades run as the deciding user
|
|
2907
|
+
* with RLS enforced. Same shape the approval node already uses when it calls
|
|
2908
|
+
* into this service (`approval-node.ts`).
|
|
2909
|
+
*
|
|
2910
|
+
* `actorId` is `null` for the genuinely machine-driven transitions (the SLA
|
|
2911
|
+
* escalation's auto-decision, the dead-run sweep). There is no human to name
|
|
2912
|
+
* there, and naming a sentinel would put a non-user in `updated_by` and in
|
|
2913
|
+
* every downstream flow's identity. Those cascades stay user-less — a flow
|
|
2914
|
+
* that wants to react to them still has to declare `runAs:'system'`, which is
|
|
2915
|
+
* the honest answer rather than an oversight.
|
|
2916
|
+
*
|
|
2917
|
+
* Deliberately carries `userId` ONLY, not the request's org. On an
|
|
2918
|
+
* ExecutionContext `tenantId` is a driver-scoping knob, not attribution
|
|
2919
|
+
* (`buildDriverOptions` turns it into a tenant predicate on the update), so
|
|
2920
|
+
* passing it would newly org-scope this write and silently no-op the mirror on
|
|
2921
|
+
* a record whose org differs from the request's — while buying nothing: the
|
|
2922
|
+
* automation engine back-fills the run's `tenantId` from the resolved user's
|
|
2923
|
+
* own grants.
|
|
2924
|
+
*/
|
|
2925
|
+
async mirrorStatusField(object, recordId, field, status, actorId) {
|
|
1902
2926
|
try {
|
|
1903
|
-
|
|
2927
|
+
const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
|
|
2928
|
+
await this.engine.update(object, { id: recordId, [field]: status }, { context });
|
|
1904
2929
|
} catch (err) {
|
|
1905
2930
|
this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
|
|
1906
2931
|
}
|
|
1907
2932
|
}
|
|
2933
|
+
/**
|
|
2934
|
+
* Re-read a business record's CURRENT state by id so approver resolution binds
|
|
2935
|
+
* to live data at node entry, not the trigger snapshot the flow froze into
|
|
2936
|
+
* `$record` at submit time (#3447). A `field` / `manager` approver names *who*
|
|
2937
|
+
* decides, and an earlier node — or the approver of an earlier step — may have
|
|
2938
|
+
* written that routing field after submit (e.g. a lead reviewer picking which
|
|
2939
|
+
* departments co-review). Graph approvers (team / position / …) already query
|
|
2940
|
+
* live; this brings the in-record types into line.
|
|
2941
|
+
*
|
|
2942
|
+
* Read under system identity: approver routing is a platform concern and the
|
|
2943
|
+
* record is the flow's own subject, so the submitter's RLS/FLS must not narrow
|
|
2944
|
+
* it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
|
|
2945
|
+
* hard-deleted between submit and node entry, or an object whose backend can't
|
|
2946
|
+
* serve a point read — warning rather than throwing so a transient miss can't
|
|
2947
|
+
* wedge an approval. That "warn but proceed" stance matches the
|
|
2948
|
+
* no-concrete-approver guard (#3424) and the "record is gone" enrichment path
|
|
2949
|
+
* that already falls back to the payload snapshot.
|
|
2950
|
+
*/
|
|
2951
|
+
async loadLiveRecord(object, recordId, fallback) {
|
|
2952
|
+
try {
|
|
2953
|
+
const rows = await this.engine.find(object, {
|
|
2954
|
+
where: { id: recordId },
|
|
2955
|
+
limit: 1,
|
|
2956
|
+
context: SYSTEM_CTX2
|
|
2957
|
+
});
|
|
2958
|
+
const live = Array.isArray(rows) ? rows[0] : rows;
|
|
2959
|
+
if (live) return live;
|
|
2960
|
+
this.logger?.warn?.(
|
|
2961
|
+
`[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
|
|
2962
|
+
{ object, recordId }
|
|
2963
|
+
);
|
|
2964
|
+
} catch (err) {
|
|
2965
|
+
this.logger?.warn?.(
|
|
2966
|
+
`[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
|
|
2967
|
+
);
|
|
2968
|
+
}
|
|
2969
|
+
return fallback ?? {};
|
|
2970
|
+
}
|
|
1908
2971
|
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
|
|
1909
2972
|
//
|
|
1910
2973
|
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
|
|
@@ -1916,6 +2979,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1916
2979
|
* Open a pending approval request on behalf of a flow's Approval node. The
|
|
1917
2980
|
* node config (approvers / behavior / status field) is snapshotted on the row
|
|
1918
2981
|
* so a decision can be made without any process to resolve against.
|
|
2982
|
+
*
|
|
2983
|
+
* #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
|
|
2984
|
+
* request opened — when the slate resolves empty and the node's
|
|
2985
|
+
* `onEmptyApprovers` policy is `auto_approve`.
|
|
1919
2986
|
*/
|
|
1920
2987
|
async openNodeRequest(input, context) {
|
|
1921
2988
|
if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
|
|
@@ -1924,7 +2991,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1924
2991
|
const existing = await this.engine.find("sys_approval_request", {
|
|
1925
2992
|
where: { object_name: input.object, record_id: input.recordId, status: "pending" },
|
|
1926
2993
|
limit: 1,
|
|
1927
|
-
context:
|
|
2994
|
+
context: SYSTEM_CTX2
|
|
1928
2995
|
});
|
|
1929
2996
|
if (Array.isArray(existing) && existing[0]) {
|
|
1930
2997
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
@@ -1933,26 +3000,56 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1933
3000
|
const nowDate = this.clock.now();
|
|
1934
3001
|
const substitutions = [];
|
|
1935
3002
|
const groups = {};
|
|
3003
|
+
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
3004
|
+
const resolvedFrom = {};
|
|
1936
3005
|
const approvers = await this.expandApprovers(
|
|
1937
3006
|
{ approvers: input.config.approvers },
|
|
1938
|
-
|
|
3007
|
+
liveRecord,
|
|
1939
3008
|
ctxOrg,
|
|
1940
|
-
{
|
|
3009
|
+
{
|
|
3010
|
+
now: nowDate.getTime(),
|
|
3011
|
+
substitutions,
|
|
3012
|
+
groups,
|
|
3013
|
+
exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
|
|
3014
|
+
resolvedFrom
|
|
3015
|
+
}
|
|
1941
3016
|
);
|
|
3017
|
+
if (!approvers.some((a) => a && !a.includes(":"))) {
|
|
3018
|
+
const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
|
|
3019
|
+
if (emptyPolicy === "fail") {
|
|
3020
|
+
throw new Error(
|
|
3021
|
+
`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.`
|
|
3022
|
+
);
|
|
3023
|
+
}
|
|
3024
|
+
if (emptyPolicy === "auto_approve") {
|
|
3025
|
+
this.logger?.warn?.(
|
|
3026
|
+
`[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).`,
|
|
3027
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
3028
|
+
);
|
|
3029
|
+
return { autoApproved: true, reason: "empty_approvers" };
|
|
3030
|
+
}
|
|
3031
|
+
this.logger?.warn?.(
|
|
3032
|
+
`[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.`,
|
|
3033
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
3034
|
+
);
|
|
3035
|
+
}
|
|
1942
3036
|
const now = nowDate.toISOString();
|
|
1943
3037
|
const id = uid("areq");
|
|
1944
3038
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1945
3039
|
const configSnapshot = { ...input.config };
|
|
1946
3040
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1947
3041
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1948
|
-
if (input.config.behavior
|
|
3042
|
+
if (input.config.behavior && input.config.behavior !== "first_response") {
|
|
1949
3043
|
configSnapshot.__approverGroups = groups;
|
|
1950
3044
|
}
|
|
3045
|
+
if (Object.keys(resolvedFrom).length) {
|
|
3046
|
+
configSnapshot.__resolvedFrom = resolvedFrom;
|
|
3047
|
+
}
|
|
1951
3048
|
try {
|
|
1952
3049
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1953
3050
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
1954
3051
|
limit: 500,
|
|
1955
|
-
context:
|
|
3052
|
+
context: SYSTEM_CTX2
|
|
1956
3053
|
});
|
|
1957
3054
|
const n = Array.isArray(prior) ? prior.length : 0;
|
|
1958
3055
|
if (n > 0) configSnapshot.__round = n + 1;
|
|
@@ -1976,7 +3073,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1976
3073
|
created_at: now,
|
|
1977
3074
|
updated_at: now
|
|
1978
3075
|
};
|
|
1979
|
-
await this.engine.insert("sys_approval_request", row, { context:
|
|
3076
|
+
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
1980
3077
|
await this.syncApproverIndex(id, approvers, ctxOrg, now);
|
|
1981
3078
|
await this.engine.insert("sys_approval_action", {
|
|
1982
3079
|
id: uid("aact"),
|
|
@@ -1988,7 +3085,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1988
3085
|
actor_id: input.submitterId ?? context.userId ?? null,
|
|
1989
3086
|
comment: null,
|
|
1990
3087
|
created_at: now
|
|
1991
|
-
}, { context:
|
|
3088
|
+
}, { context: SYSTEM_CTX2 });
|
|
1992
3089
|
for (const sub of substitutions) {
|
|
1993
3090
|
await this.engine.insert("sys_approval_action", {
|
|
1994
3091
|
id: uid("aact"),
|
|
@@ -2000,7 +3097,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2000
3097
|
actor_id: null,
|
|
2001
3098
|
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2002
3099
|
created_at: now
|
|
2003
|
-
}, { context:
|
|
3100
|
+
}, { context: SYSTEM_CTX2 });
|
|
2004
3101
|
await this.notify({
|
|
2005
3102
|
topic: "approval.ooo_substituted",
|
|
2006
3103
|
audience: [sub.to],
|
|
@@ -2025,7 +3122,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2025
3122
|
});
|
|
2026
3123
|
}
|
|
2027
3124
|
if (input.config.approvalStatusField) {
|
|
2028
|
-
await this.mirrorStatusField(
|
|
3125
|
+
await this.mirrorStatusField(
|
|
3126
|
+
input.object,
|
|
3127
|
+
input.recordId,
|
|
3128
|
+
input.config.approvalStatusField,
|
|
3129
|
+
"pending",
|
|
3130
|
+
row.submitter_id ?? null
|
|
3131
|
+
);
|
|
2029
3132
|
}
|
|
2030
3133
|
return rowFromRequest(row);
|
|
2031
3134
|
}
|
|
@@ -2069,27 +3172,61 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2069
3172
|
*/
|
|
2070
3173
|
async decideNode(requestId, input, context) {
|
|
2071
3174
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2072
|
-
|
|
3175
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2073
3176
|
if (input.decision !== "approve" && input.decision !== "reject") {
|
|
2074
3177
|
throw new Error("VALIDATION_FAILED: decision must be approve|reject");
|
|
2075
3178
|
}
|
|
2076
3179
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2077
3180
|
where: { id: requestId },
|
|
2078
3181
|
limit: 1,
|
|
2079
|
-
context:
|
|
3182
|
+
context: SYSTEM_CTX2
|
|
2080
3183
|
});
|
|
2081
3184
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2082
3185
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2083
3186
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2084
3187
|
const pendingApprovers = csvSplit(raw.pending_approvers);
|
|
2085
|
-
|
|
2086
|
-
|
|
3188
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3189
|
+
const isSlotHolder = pendingApprovers.includes(actorId);
|
|
3190
|
+
if (!isSlotHolder && !isOverride) {
|
|
3191
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2087
3192
|
}
|
|
2088
3193
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2089
3194
|
const org = raw.organization_id ?? null;
|
|
2090
3195
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
2091
3196
|
const runId = raw.flow_run_id ?? null;
|
|
2092
3197
|
const now = this.clock.now().toISOString();
|
|
3198
|
+
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
3199
|
+
let acceptedOutputs;
|
|
3200
|
+
const declaredDefs = normalizeDecisionOutputs(config.decisionOutputs);
|
|
3201
|
+
if (outputKeys.length) {
|
|
3202
|
+
const declared = declaredDefs.map((d) => d.key);
|
|
3203
|
+
if (!declared.length) {
|
|
3204
|
+
throw new Error(
|
|
3205
|
+
`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.`
|
|
3206
|
+
);
|
|
3207
|
+
}
|
|
3208
|
+
const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
|
|
3209
|
+
if (reserved.length) {
|
|
3210
|
+
throw new Error(
|
|
3211
|
+
`VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
|
|
3212
|
+
);
|
|
3213
|
+
}
|
|
3214
|
+
const undeclared = outputKeys.filter((k) => !declared.includes(k));
|
|
3215
|
+
if (undeclared.length) {
|
|
3216
|
+
throw new Error(
|
|
3217
|
+
`VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
|
|
3218
|
+
);
|
|
3219
|
+
}
|
|
3220
|
+
acceptedOutputs = { ...input.outputs };
|
|
3221
|
+
}
|
|
3222
|
+
if (input.decision === "approve") {
|
|
3223
|
+
const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
|
|
3224
|
+
if (missing.length) {
|
|
3225
|
+
throw new Error(
|
|
3226
|
+
`VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
|
|
3227
|
+
);
|
|
3228
|
+
}
|
|
3229
|
+
}
|
|
2093
3230
|
await this.engine.insert("sys_approval_action", {
|
|
2094
3231
|
id: uid("aact"),
|
|
2095
3232
|
request_id: requestId,
|
|
@@ -2097,23 +3234,23 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2097
3234
|
step_name: nodeId,
|
|
2098
3235
|
step_index: 0,
|
|
2099
3236
|
action: input.decision,
|
|
2100
|
-
actor_id:
|
|
3237
|
+
actor_id: actorId,
|
|
2101
3238
|
comment: input.comment ?? null,
|
|
2102
3239
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2103
3240
|
created_at: now
|
|
2104
|
-
}, { context:
|
|
3241
|
+
}, { context: SYSTEM_CTX2 });
|
|
2105
3242
|
const behavior = config.behavior ?? "first_response";
|
|
2106
|
-
if (input.decision === "approve" && behavior !== "first_response") {
|
|
3243
|
+
if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
|
|
2107
3244
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2108
3245
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
2109
3246
|
limit: 1e3,
|
|
2110
|
-
context:
|
|
3247
|
+
context: SYSTEM_CTX2
|
|
2111
3248
|
});
|
|
2112
3249
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
2113
3250
|
const snapshotGroups = config.__approverGroups;
|
|
2114
3251
|
let original;
|
|
2115
3252
|
let groupMap;
|
|
2116
|
-
if (snapshotGroups
|
|
3253
|
+
if (snapshotGroups) {
|
|
2117
3254
|
groupMap = snapshotGroups;
|
|
2118
3255
|
original = Object.keys(snapshotGroups);
|
|
2119
3256
|
} else {
|
|
@@ -2129,27 +3266,65 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2129
3266
|
await this.engine.update("sys_approval_request", {
|
|
2130
3267
|
id: requestId,
|
|
2131
3268
|
pending_approvers: stillPending.join(","),
|
|
2132
|
-
updated_at: now
|
|
2133
|
-
|
|
3269
|
+
updated_at: now,
|
|
3270
|
+
// #3447 P2: a mid-tally approval may carry outputs too (unanimous /
|
|
3271
|
+
// per_group co-sign, each approver contributing their declared keys)
|
|
3272
|
+
// — accumulate them on the snapshot so the FINALIZING decision hands
|
|
3273
|
+
// the merged set to the flow.
|
|
3274
|
+
...acceptedOutputs ? {
|
|
3275
|
+
node_config_json: JSON.stringify({
|
|
3276
|
+
...config,
|
|
3277
|
+
__decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
|
|
3278
|
+
})
|
|
3279
|
+
} : {}
|
|
3280
|
+
}, { context: SYSTEM_CTX2 });
|
|
2134
3281
|
await this.syncApproverIndex(requestId, stillPending, org, now);
|
|
2135
|
-
const fresh2 = await this.
|
|
3282
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2136
3283
|
return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
|
|
2137
3284
|
}
|
|
2138
3285
|
}
|
|
2139
3286
|
const finalStatus = input.decision === "approve" ? "approved" : "rejected";
|
|
3287
|
+
const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
|
|
2140
3288
|
await this.engine.update("sys_approval_request", {
|
|
2141
3289
|
id: requestId,
|
|
2142
3290
|
status: finalStatus,
|
|
2143
3291
|
pending_approvers: null,
|
|
2144
3292
|
completed_at: now,
|
|
2145
|
-
updated_at: now
|
|
2146
|
-
|
|
3293
|
+
updated_at: now,
|
|
3294
|
+
...mergedOutputs ? {
|
|
3295
|
+
node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
|
|
3296
|
+
} : {}
|
|
3297
|
+
}, { context: SYSTEM_CTX2 });
|
|
2147
3298
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2148
3299
|
if (config.approvalStatusField) {
|
|
2149
|
-
await this.mirrorStatusField(
|
|
3300
|
+
await this.mirrorStatusField(
|
|
3301
|
+
raw.object_name,
|
|
3302
|
+
raw.record_id,
|
|
3303
|
+
config.approvalStatusField,
|
|
3304
|
+
finalStatus,
|
|
3305
|
+
actingUserId(context)
|
|
3306
|
+
);
|
|
2150
3307
|
}
|
|
2151
|
-
const fresh = await this.
|
|
2152
|
-
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
|
|
3308
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
3309
|
+
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
|
|
3310
|
+
}
|
|
3311
|
+
/**
|
|
3312
|
+
* Continue the owning flow run after an outcome this service has already
|
|
3313
|
+
* authorized and written down (#3801).
|
|
3314
|
+
*
|
|
3315
|
+
* The `approval` node declares `resumeAuthority: 'service'`, so the engine
|
|
3316
|
+
* refuses any resume of an approval suspension that does not carry
|
|
3317
|
+
* {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
|
|
3318
|
+
* here so the marker is stamped in ONE place — a new outcome path cannot
|
|
3319
|
+
* quietly ship a resume that the gate then rejects at runtime, and nothing
|
|
3320
|
+
* in this file hands the marker to a caller-supplied signal.
|
|
3321
|
+
*
|
|
3322
|
+
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3323
|
+
* (approvals runs fine with no automation attached) and keep their own
|
|
3324
|
+
* try/catch, because what a failed resume means differs per path.
|
|
3325
|
+
*/
|
|
3326
|
+
async serviceResume(runId, signal) {
|
|
3327
|
+
await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
|
|
2153
3328
|
}
|
|
2154
3329
|
/**
|
|
2155
3330
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
@@ -2162,9 +3337,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2162
3337
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
2163
3338
|
const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
|
|
2164
3339
|
try {
|
|
2165
|
-
await this.
|
|
3340
|
+
await this.serviceResume(result.runId, {
|
|
2166
3341
|
branchLabel,
|
|
2167
|
-
|
|
3342
|
+
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3343
|
+
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
3344
|
+
// `expression` approver reads them as `vars.<nodeId>.<key>`.
|
|
3345
|
+
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3346
|
+
// whitelist already rejects them; this is defense in depth).
|
|
3347
|
+
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
2168
3348
|
});
|
|
2169
3349
|
resumed = true;
|
|
2170
3350
|
} catch (err) {
|
|
@@ -2198,11 +3378,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2198
3378
|
*/
|
|
2199
3379
|
async recall(requestId, input, context) {
|
|
2200
3380
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2201
|
-
|
|
3381
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2202
3382
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2203
3383
|
where: { id: requestId },
|
|
2204
3384
|
limit: 1,
|
|
2205
|
-
context:
|
|
3385
|
+
context: SYSTEM_CTX2
|
|
2206
3386
|
});
|
|
2207
3387
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2208
3388
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
@@ -2210,7 +3390,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2210
3390
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
2211
3391
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2212
3392
|
}
|
|
2213
|
-
if (!context.
|
|
3393
|
+
if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2214
3394
|
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
2215
3395
|
}
|
|
2216
3396
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
@@ -2226,20 +3406,26 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2226
3406
|
step_name: nodeId,
|
|
2227
3407
|
step_index: 0,
|
|
2228
3408
|
action: "recall",
|
|
2229
|
-
actor_id:
|
|
3409
|
+
actor_id: actorId,
|
|
2230
3410
|
comment: input.comment ?? null,
|
|
2231
3411
|
created_at: now
|
|
2232
|
-
}, { context:
|
|
3412
|
+
}, { context: SYSTEM_CTX2 });
|
|
2233
3413
|
await this.engine.update("sys_approval_request", {
|
|
2234
3414
|
id: requestId,
|
|
2235
3415
|
status: "recalled",
|
|
2236
3416
|
pending_approvers: null,
|
|
2237
3417
|
completed_at: now,
|
|
2238
3418
|
updated_at: now
|
|
2239
|
-
}, { context:
|
|
3419
|
+
}, { context: SYSTEM_CTX2 });
|
|
2240
3420
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2241
3421
|
if (config.approvalStatusField) {
|
|
2242
|
-
await this.mirrorStatusField(
|
|
3422
|
+
await this.mirrorStatusField(
|
|
3423
|
+
raw.object_name,
|
|
3424
|
+
raw.record_id,
|
|
3425
|
+
config.approvalStatusField,
|
|
3426
|
+
"recalled",
|
|
3427
|
+
actingUserId(context)
|
|
3428
|
+
);
|
|
2243
3429
|
}
|
|
2244
3430
|
let resumed = false;
|
|
2245
3431
|
if (inReviseWindow) {
|
|
@@ -2256,7 +3442,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2256
3442
|
}
|
|
2257
3443
|
} else if (runId && typeof this.automation?.resume === "function") {
|
|
2258
3444
|
try {
|
|
2259
|
-
await this.
|
|
3445
|
+
await this.serviceResume(runId, {
|
|
2260
3446
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
2261
3447
|
output: { decision: "recall", requestId }
|
|
2262
3448
|
});
|
|
@@ -2269,7 +3455,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2269
3455
|
});
|
|
2270
3456
|
}
|
|
2271
3457
|
}
|
|
2272
|
-
const fresh = await this.
|
|
3458
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2273
3459
|
return { request: fresh, runId, resumed };
|
|
2274
3460
|
}
|
|
2275
3461
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
@@ -2287,11 +3473,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2287
3473
|
* `output.autoRejected = true`) so instances cannot orbit forever.
|
|
2288
3474
|
*/
|
|
2289
3475
|
async sendBack(requestId, input, context) {
|
|
2290
|
-
|
|
3476
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2291
3477
|
const raw = await this.loadPendingRow(requestId);
|
|
2292
3478
|
const pending = csvSplit(raw.pending_approvers);
|
|
2293
|
-
if (!context.isSystem && !pending.includes(
|
|
2294
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3479
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3480
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2295
3481
|
}
|
|
2296
3482
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2297
3483
|
const org = raw.organization_id ?? null;
|
|
@@ -2305,7 +3491,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2305
3491
|
const siblings = await this.engine.find("sys_approval_request", {
|
|
2306
3492
|
where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
|
|
2307
3493
|
limit: 500,
|
|
2308
|
-
context:
|
|
3494
|
+
context: SYSTEM_CTX2
|
|
2309
3495
|
});
|
|
2310
3496
|
priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
|
|
2311
3497
|
}
|
|
@@ -2316,10 +3502,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2316
3502
|
step_name: nodeId,
|
|
2317
3503
|
step_index: 0,
|
|
2318
3504
|
action: "revise",
|
|
2319
|
-
actor_id:
|
|
3505
|
+
actor_id: actorId,
|
|
2320
3506
|
comment: input.comment ?? null,
|
|
2321
3507
|
created_at: now
|
|
2322
|
-
}, { context:
|
|
3508
|
+
}, { context: SYSTEM_CTX2 });
|
|
2323
3509
|
if (priorSendBacks >= maxRevisions) {
|
|
2324
3510
|
await this.engine.insert("sys_approval_action", {
|
|
2325
3511
|
id: uid("aact"),
|
|
@@ -2328,25 +3514,31 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2328
3514
|
step_name: nodeId,
|
|
2329
3515
|
step_index: 0,
|
|
2330
3516
|
action: "reject",
|
|
2331
|
-
actor_id:
|
|
3517
|
+
actor_id: actorId,
|
|
2332
3518
|
comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
|
|
2333
3519
|
created_at: now
|
|
2334
|
-
}, { context:
|
|
3520
|
+
}, { context: SYSTEM_CTX2 });
|
|
2335
3521
|
await this.engine.update("sys_approval_request", {
|
|
2336
3522
|
id: requestId,
|
|
2337
3523
|
status: "rejected",
|
|
2338
3524
|
pending_approvers: null,
|
|
2339
3525
|
completed_at: now,
|
|
2340
3526
|
updated_at: now
|
|
2341
|
-
}, { context:
|
|
3527
|
+
}, { context: SYSTEM_CTX2 });
|
|
2342
3528
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2343
3529
|
if (config.approvalStatusField) {
|
|
2344
|
-
await this.mirrorStatusField(
|
|
3530
|
+
await this.mirrorStatusField(
|
|
3531
|
+
raw.object_name,
|
|
3532
|
+
raw.record_id,
|
|
3533
|
+
config.approvalStatusField,
|
|
3534
|
+
"rejected",
|
|
3535
|
+
actingUserId(context)
|
|
3536
|
+
);
|
|
2345
3537
|
}
|
|
2346
3538
|
let resumed2 = false;
|
|
2347
3539
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2348
3540
|
try {
|
|
2349
|
-
await this.
|
|
3541
|
+
await this.serviceResume(runId, {
|
|
2350
3542
|
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
2351
3543
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
2352
3544
|
});
|
|
@@ -2363,7 +3555,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2363
3555
|
await this.notify({
|
|
2364
3556
|
topic: "approval.returned",
|
|
2365
3557
|
audience: [String(raw.submitter_id)],
|
|
2366
|
-
actorId
|
|
3558
|
+
actorId,
|
|
2367
3559
|
source: { object: "sys_approval_request", id: requestId },
|
|
2368
3560
|
payload: {
|
|
2369
3561
|
title: "Approval auto-rejected",
|
|
@@ -2372,7 +3564,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2372
3564
|
}
|
|
2373
3565
|
});
|
|
2374
3566
|
}
|
|
2375
|
-
const fresh2 = await this.
|
|
3567
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2376
3568
|
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
2377
3569
|
}
|
|
2378
3570
|
await this.engine.update("sys_approval_request", {
|
|
@@ -2381,15 +3573,21 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2381
3573
|
pending_approvers: null,
|
|
2382
3574
|
completed_at: now,
|
|
2383
3575
|
updated_at: now
|
|
2384
|
-
}, { context:
|
|
3576
|
+
}, { context: SYSTEM_CTX2 });
|
|
2385
3577
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2386
3578
|
if (config.approvalStatusField) {
|
|
2387
|
-
await this.mirrorStatusField(
|
|
3579
|
+
await this.mirrorStatusField(
|
|
3580
|
+
raw.object_name,
|
|
3581
|
+
raw.record_id,
|
|
3582
|
+
config.approvalStatusField,
|
|
3583
|
+
"returned",
|
|
3584
|
+
actingUserId(context)
|
|
3585
|
+
);
|
|
2388
3586
|
}
|
|
2389
3587
|
let resumed = false;
|
|
2390
3588
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2391
3589
|
try {
|
|
2392
|
-
await this.
|
|
3590
|
+
await this.serviceResume(runId, {
|
|
2393
3591
|
branchLabel: APPROVAL_BRANCH_LABELS.revise,
|
|
2394
3592
|
output: { decision: "revise", requestId }
|
|
2395
3593
|
});
|
|
@@ -2406,7 +3604,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2406
3604
|
await this.notify({
|
|
2407
3605
|
topic: "approval.returned",
|
|
2408
3606
|
audience: [String(raw.submitter_id)],
|
|
2409
|
-
actorId
|
|
3607
|
+
actorId,
|
|
2410
3608
|
source: { object: "sys_approval_request", id: requestId },
|
|
2411
3609
|
payload: {
|
|
2412
3610
|
title: "Sent back for revision",
|
|
@@ -2415,7 +3613,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2415
3613
|
}
|
|
2416
3614
|
});
|
|
2417
3615
|
}
|
|
2418
|
-
const fresh = await this.
|
|
3616
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2419
3617
|
return { request: fresh, runId, resumed };
|
|
2420
3618
|
}
|
|
2421
3619
|
/**
|
|
@@ -2426,25 +3624,25 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2426
3624
|
* round-N+1 request — fresh approver slate, record re-locks.
|
|
2427
3625
|
*/
|
|
2428
3626
|
async resubmit(requestId, input, context) {
|
|
2429
|
-
|
|
3627
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2430
3628
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2431
3629
|
where: { id: requestId },
|
|
2432
3630
|
limit: 1,
|
|
2433
|
-
context:
|
|
3631
|
+
context: SYSTEM_CTX2
|
|
2434
3632
|
});
|
|
2435
3633
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2436
3634
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2437
3635
|
if (raw.status !== "returned") {
|
|
2438
3636
|
throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
|
|
2439
3637
|
}
|
|
2440
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3638
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2441
3639
|
throw new Error("FORBIDDEN: only the submitter may resubmit");
|
|
2442
3640
|
}
|
|
2443
3641
|
await this.assertLatestForRun(raw);
|
|
2444
3642
|
const colliding = await this.engine.find("sys_approval_request", {
|
|
2445
3643
|
where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
|
|
2446
3644
|
limit: 1,
|
|
2447
|
-
context:
|
|
3645
|
+
context: SYSTEM_CTX2
|
|
2448
3646
|
});
|
|
2449
3647
|
if (Array.isArray(colliding) && colliding[0]) {
|
|
2450
3648
|
throw new Error(
|
|
@@ -2462,14 +3660,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2462
3660
|
step_name: nodeId,
|
|
2463
3661
|
step_index: 0,
|
|
2464
3662
|
action: "resubmit",
|
|
2465
|
-
actor_id:
|
|
3663
|
+
actor_id: actorId,
|
|
2466
3664
|
comment: input.comment ?? null,
|
|
2467
3665
|
created_at: now
|
|
2468
|
-
}, { context:
|
|
3666
|
+
}, { context: SYSTEM_CTX2 });
|
|
2469
3667
|
let resumed = false;
|
|
2470
3668
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2471
3669
|
try {
|
|
2472
|
-
await this.
|
|
3670
|
+
await this.serviceResume(runId, {
|
|
2473
3671
|
branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
|
|
2474
3672
|
output: { resubmitted: true, requestId }
|
|
2475
3673
|
});
|
|
@@ -2482,7 +3680,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2482
3680
|
});
|
|
2483
3681
|
}
|
|
2484
3682
|
}
|
|
2485
|
-
const fresh = await this.
|
|
3683
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2486
3684
|
return { request: fresh, runId, resumed };
|
|
2487
3685
|
}
|
|
2488
3686
|
/**
|
|
@@ -2517,7 +3715,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2517
3715
|
where: { flow_run_id: runId },
|
|
2518
3716
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2519
3717
|
limit: 1,
|
|
2520
|
-
context:
|
|
3718
|
+
context: SYSTEM_CTX2
|
|
2521
3719
|
});
|
|
2522
3720
|
const latest = Array.isArray(rows) ? rows[0] : null;
|
|
2523
3721
|
if (latest && String(latest.id) !== String(raw.id)) {
|
|
@@ -2528,25 +3726,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2528
3726
|
/**
|
|
2529
3727
|
* Hand a pending-approver slot to someone else. `from` defaults to the
|
|
2530
3728
|
* actor itself; the actor must hold the slot being handed over (or be a
|
|
2531
|
-
* system caller).
|
|
3729
|
+
* system caller). A privileged admin (#3424) may reassign a request whose
|
|
3730
|
+
* slate holds no real user — an unstaffed-position literal — by handing the
|
|
3731
|
+
* whole request to a real approver, rescuing it from the locked dead-end.
|
|
3732
|
+
* Audits `reassign` and notifies the new approver.
|
|
2532
3733
|
*/
|
|
2533
3734
|
async reassign(requestId, input, context) {
|
|
2534
|
-
|
|
3735
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2535
3736
|
const to = String(input?.to ?? "").trim();
|
|
2536
3737
|
if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
|
|
2537
3738
|
const raw = await this.loadPendingRow(requestId);
|
|
2538
3739
|
const pending = csvSplit(raw.pending_approvers);
|
|
2539
|
-
const from = String(input.from ?? input.actorId).trim();
|
|
2540
|
-
if (!pending.includes(from)) {
|
|
2541
|
-
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
2542
|
-
}
|
|
2543
|
-
if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
|
|
2544
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
2545
|
-
}
|
|
2546
3740
|
if (pending.includes(to)) {
|
|
2547
3741
|
throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
|
|
2548
3742
|
}
|
|
2549
|
-
const
|
|
3743
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3744
|
+
const from = String(input.from ?? actorId).trim();
|
|
3745
|
+
let next;
|
|
3746
|
+
if (pending.includes(from)) {
|
|
3747
|
+
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
3748
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3749
|
+
}
|
|
3750
|
+
next = pending.map((a) => a === from ? to : a);
|
|
3751
|
+
} else if (isOverride) {
|
|
3752
|
+
next = [to];
|
|
3753
|
+
} else {
|
|
3754
|
+
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
3755
|
+
}
|
|
2550
3756
|
const now = this.clock.now().toISOString();
|
|
2551
3757
|
await this.engine.insert("sys_approval_action", {
|
|
2552
3758
|
id: uid("aact"),
|
|
@@ -2555,10 +3761,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2555
3761
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2556
3762
|
step_index: 0,
|
|
2557
3763
|
action: "reassign",
|
|
2558
|
-
|
|
2559
|
-
|
|
3764
|
+
// The hand-off parties are STRUCTURED fields (#4365) — the old default
|
|
3765
|
+
// comment (`"<from> → <to>"`) baked raw user ids into user-facing text.
|
|
3766
|
+
// `comment` is pure user input: absent unless the actor wrote one.
|
|
3767
|
+
actor_id: actorId,
|
|
3768
|
+
reassign_from: from,
|
|
3769
|
+
reassign_to: to,
|
|
3770
|
+
comment: input.comment ?? null,
|
|
2560
3771
|
created_at: now
|
|
2561
|
-
}, { context:
|
|
3772
|
+
}, { context: SYSTEM_CTX2 });
|
|
2562
3773
|
let configPatch = {};
|
|
2563
3774
|
try {
|
|
2564
3775
|
const cfg = parseJson(raw.node_config_json, null);
|
|
@@ -2575,12 +3786,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2575
3786
|
pending_approvers: next.join(","),
|
|
2576
3787
|
updated_at: now,
|
|
2577
3788
|
...configPatch
|
|
2578
|
-
}, { context:
|
|
3789
|
+
}, { context: SYSTEM_CTX2 });
|
|
2579
3790
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
2580
3791
|
await this.notify({
|
|
2581
3792
|
topic: "approval.reassigned",
|
|
2582
3793
|
audience: [to],
|
|
2583
|
-
actorId
|
|
3794
|
+
actorId,
|
|
2584
3795
|
source: { object: "sys_approval_request", id: requestId },
|
|
2585
3796
|
dedupKey: `approval-reassign-${requestId}-${to}`,
|
|
2586
3797
|
payload: {
|
|
@@ -2589,7 +3800,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2589
3800
|
actionUrl: "/system/approvals"
|
|
2590
3801
|
}
|
|
2591
3802
|
});
|
|
2592
|
-
const fresh = await this.
|
|
3803
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2593
3804
|
return { request: fresh };
|
|
2594
3805
|
}
|
|
2595
3806
|
/**
|
|
@@ -2597,16 +3808,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2597
3808
|
* reminder per {@link REMIND_COOLDOWN_MS} per request.
|
|
2598
3809
|
*/
|
|
2599
3810
|
async remind(requestId, input, context) {
|
|
2600
|
-
|
|
3811
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2601
3812
|
const raw = await this.loadPendingRow(requestId);
|
|
2602
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3813
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2603
3814
|
throw new Error("FORBIDDEN: only the submitter may send reminders");
|
|
2604
3815
|
}
|
|
2605
3816
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2606
3817
|
where: { request_id: requestId, action: "remind" },
|
|
2607
3818
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2608
3819
|
limit: 1,
|
|
2609
|
-
context:
|
|
3820
|
+
context: SYSTEM_CTX2
|
|
2610
3821
|
});
|
|
2611
3822
|
const last = Array.isArray(acts) ? acts[0] : null;
|
|
2612
3823
|
const now = this.clock.now();
|
|
@@ -2622,10 +3833,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2622
3833
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2623
3834
|
step_index: 0,
|
|
2624
3835
|
action: "remind",
|
|
2625
|
-
actor_id:
|
|
3836
|
+
actor_id: actorId,
|
|
2626
3837
|
comment: input.comment ?? null,
|
|
2627
3838
|
created_at: nowIso
|
|
2628
|
-
}, { context:
|
|
3839
|
+
}, { context: SYSTEM_CTX2 });
|
|
2629
3840
|
let notified = 0;
|
|
2630
3841
|
const concrete = pending.filter((a) => a && !a.includes(":"));
|
|
2631
3842
|
const literals = pending.filter((a) => a && a.includes(":"));
|
|
@@ -2635,7 +3846,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2635
3846
|
notified += await this.notify({
|
|
2636
3847
|
topic: "approval.reminder",
|
|
2637
3848
|
audience: [approver],
|
|
2638
|
-
actorId
|
|
3849
|
+
actorId,
|
|
2639
3850
|
source: { object: "sys_approval_request", id: requestId },
|
|
2640
3851
|
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
|
|
2641
3852
|
payload: {
|
|
@@ -2660,7 +3871,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2660
3871
|
notified += await this.notify({
|
|
2661
3872
|
topic: "approval.reminder",
|
|
2662
3873
|
audience: literals,
|
|
2663
|
-
actorId
|
|
3874
|
+
actorId,
|
|
2664
3875
|
source: { object: "sys_approval_request", id: requestId },
|
|
2665
3876
|
dedupKey: `approval-remind-${requestId}-${nowIso}`,
|
|
2666
3877
|
payload: {
|
|
@@ -2670,7 +3881,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2670
3881
|
}
|
|
2671
3882
|
});
|
|
2672
3883
|
}
|
|
2673
|
-
const fresh = await this.
|
|
3884
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2674
3885
|
return { request: fresh, notified };
|
|
2675
3886
|
}
|
|
2676
3887
|
// ── Actionable links (ADR-0043) ──────────────────────────────
|
|
@@ -2705,7 +3916,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2705
3916
|
expires_at: expires,
|
|
2706
3917
|
consumed_at: null,
|
|
2707
3918
|
created_at: now.toISOString()
|
|
2708
|
-
}, { context:
|
|
3919
|
+
}, { context: SYSTEM_CTX2 });
|
|
2709
3920
|
out[action] = rawToken;
|
|
2710
3921
|
}
|
|
2711
3922
|
return out;
|
|
@@ -2718,7 +3929,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2718
3929
|
const rows = await this.engine.find("sys_approval_token", {
|
|
2719
3930
|
where: { token_hash: hash },
|
|
2720
3931
|
limit: 1,
|
|
2721
|
-
context:
|
|
3932
|
+
context: SYSTEM_CTX2
|
|
2722
3933
|
});
|
|
2723
3934
|
const token = Array.isArray(rows) ? rows[0] : null;
|
|
2724
3935
|
if (!token) return { ok: false, reason: "invalid" };
|
|
@@ -2726,7 +3937,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2726
3937
|
if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
|
|
2727
3938
|
return { ok: false, reason: "expired" };
|
|
2728
3939
|
}
|
|
2729
|
-
const request = await this.getRequest(token.request_id,
|
|
3940
|
+
const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
|
|
2730
3941
|
if (!request || request.status !== "pending") {
|
|
2731
3942
|
return { ok: false, reason: "not_pending", request: request ?? void 0 };
|
|
2732
3943
|
}
|
|
@@ -2751,12 +3962,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2751
3962
|
await this.engine.update("sys_approval_token", {
|
|
2752
3963
|
id: res.token.id,
|
|
2753
3964
|
consumed_at: this.clock.now().toISOString()
|
|
2754
|
-
}, { context:
|
|
3965
|
+
}, { context: SYSTEM_CTX2 });
|
|
2755
3966
|
const out = await this.decide(res.token.request_id, {
|
|
2756
3967
|
decision: res.token.action,
|
|
2757
3968
|
actorId: res.token.approver_id,
|
|
2758
3969
|
comment: "Via action link"
|
|
2759
|
-
|
|
3970
|
+
// The token IS the authentication (#3783): it is single-use, hashed at
|
|
3971
|
+
// rest and bound to one approver, who `resolveActionToken` has just
|
|
3972
|
+
// re-checked still holds a pending slot. So this decision has a real
|
|
3973
|
+
// acting user even though no session carried it — name them on the
|
|
3974
|
+
// context, so the status mirror and every flow it cascades into are
|
|
3975
|
+
// attributed exactly like a decision made through the UI. Elevation is
|
|
3976
|
+
// unchanged: `isSystem` still stands in for the missing session.
|
|
3977
|
+
}, { ...SYSTEM_CTX2, userId: res.token.approver_id });
|
|
2760
3978
|
return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
|
|
2761
3979
|
}
|
|
2762
3980
|
/**
|
|
@@ -2764,12 +3982,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2764
3982
|
* pending — a thread interaction, not a flow decision.
|
|
2765
3983
|
*/
|
|
2766
3984
|
async requestInfo(requestId, input, context) {
|
|
2767
|
-
|
|
3985
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2768
3986
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2769
3987
|
const raw = await this.loadPendingRow(requestId);
|
|
2770
3988
|
const pending = csvSplit(raw.pending_approvers);
|
|
2771
|
-
if (!context.isSystem && !pending.includes(
|
|
2772
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3989
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3990
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2773
3991
|
}
|
|
2774
3992
|
const now = this.clock.now().toISOString();
|
|
2775
3993
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2779,15 +3997,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2779
3997
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2780
3998
|
step_index: 0,
|
|
2781
3999
|
action: "request_info",
|
|
2782
|
-
actor_id:
|
|
4000
|
+
actor_id: actorId,
|
|
2783
4001
|
comment: input.comment.trim(),
|
|
2784
4002
|
created_at: now
|
|
2785
|
-
}, { context:
|
|
4003
|
+
}, { context: SYSTEM_CTX2 });
|
|
2786
4004
|
if (raw.submitter_id) {
|
|
2787
4005
|
await this.notify({
|
|
2788
4006
|
topic: "approval.request_info",
|
|
2789
4007
|
audience: [String(raw.submitter_id)],
|
|
2790
|
-
actorId
|
|
4008
|
+
actorId,
|
|
2791
4009
|
source: { object: "sys_approval_request", id: requestId },
|
|
2792
4010
|
payload: {
|
|
2793
4011
|
title: "More information requested",
|
|
@@ -2796,18 +4014,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2796
4014
|
}
|
|
2797
4015
|
});
|
|
2798
4016
|
}
|
|
2799
|
-
const fresh = await this.
|
|
4017
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2800
4018
|
return { request: fresh };
|
|
2801
4019
|
}
|
|
2802
4020
|
/** Free-form reply on the thread (submitter or any pending approver). */
|
|
2803
4021
|
async comment(requestId, input, context) {
|
|
2804
|
-
|
|
4022
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2805
4023
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2806
4024
|
const raw = await this.loadPendingRow(requestId);
|
|
2807
4025
|
const pending = csvSplit(raw.pending_approvers);
|
|
2808
|
-
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(
|
|
2809
|
-
if (!context.isSystem && !isSubmitter && !pending.includes(
|
|
2810
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
4026
|
+
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
|
|
4027
|
+
if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
|
|
4028
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
|
|
2811
4029
|
}
|
|
2812
4030
|
const now = this.clock.now().toISOString();
|
|
2813
4031
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2817,16 +4035,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2817
4035
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2818
4036
|
step_index: 0,
|
|
2819
4037
|
action: "comment",
|
|
2820
|
-
actor_id:
|
|
4038
|
+
actor_id: actorId,
|
|
2821
4039
|
comment: input.comment.trim(),
|
|
2822
4040
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2823
4041
|
created_at: now
|
|
2824
|
-
}, { context:
|
|
4042
|
+
}, { context: SYSTEM_CTX2 });
|
|
2825
4043
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
2826
4044
|
await this.notify({
|
|
2827
4045
|
topic: "approval.comment",
|
|
2828
4046
|
audience,
|
|
2829
|
-
actorId
|
|
4047
|
+
actorId,
|
|
2830
4048
|
source: { object: "sys_approval_request", id: requestId },
|
|
2831
4049
|
payload: {
|
|
2832
4050
|
title: "New comment on an approval",
|
|
@@ -2834,7 +4052,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2834
4052
|
actionUrl: "/system/approvals"
|
|
2835
4053
|
}
|
|
2836
4054
|
});
|
|
2837
|
-
const fresh = await this.
|
|
4055
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2838
4056
|
return { request: fresh };
|
|
2839
4057
|
}
|
|
2840
4058
|
// ── SLA escalation (ADR-0042) ─────────────────────────────────
|
|
@@ -2851,7 +4069,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2851
4069
|
rows = await this.engine.find("sys_approval_request", {
|
|
2852
4070
|
where: { status: "pending" },
|
|
2853
4071
|
limit: 500,
|
|
2854
|
-
context:
|
|
4072
|
+
context: SYSTEM_CTX2
|
|
2855
4073
|
}) ?? [];
|
|
2856
4074
|
} catch (err) {
|
|
2857
4075
|
this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
|
|
@@ -2870,7 +4088,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2870
4088
|
const prior = await this.engine.find("sys_approval_action", {
|
|
2871
4089
|
where: { request_id: raw.id, action: "escalate" },
|
|
2872
4090
|
limit: 1,
|
|
2873
|
-
context:
|
|
4091
|
+
context: SYSTEM_CTX2
|
|
2874
4092
|
});
|
|
2875
4093
|
if (Array.isArray(prior) && prior[0]) continue;
|
|
2876
4094
|
await this.escalateRequest(raw, esc2);
|
|
@@ -2887,6 +4105,130 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2887
4105
|
}
|
|
2888
4106
|
return { scanned: rows.length, escalated };
|
|
2889
4107
|
}
|
|
4108
|
+
// ── Dead-run release (#3456) ──────────────────────────────────
|
|
4109
|
+
/**
|
|
4110
|
+
* One dead-run sweep: a pending request whose owning flow run has reached a
|
|
4111
|
+
* TERMINAL state can never be decided — nothing is left to resume — so the
|
|
4112
|
+
* request is finalised as `recalled` and, with `lockRecord`, the record it was
|
|
4113
|
+
* holding is released.
|
|
4114
|
+
*
|
|
4115
|
+
* This is the recovery half of #3456. The prevention half is the record lock's
|
|
4116
|
+
* owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
|
|
4117
|
+
* itself on its own lock in the first place; this sweep cleans up the runs that
|
|
4118
|
+
* still die — for any reason, including a process crash, which no in-band
|
|
4119
|
+
* handler can catch because the process that would have run it is gone.
|
|
4120
|
+
*
|
|
4121
|
+
* **Fail-safe by construction.** It acts only on an explicit terminal status
|
|
4122
|
+
* from a closed set. Every other answer — `paused` (the normal state of a run
|
|
4123
|
+
* waiting on its approval), `running`, an unrecognised status, `null` (unknown
|
|
4124
|
+
* run, evicted log, no durable store), a `getRun` that throws, or no automation
|
|
4125
|
+
* engine at all — is read as "still alive" and left strictly alone. The failure
|
|
4126
|
+
* mode is therefore "a dead run's lock survives until an admin recalls it"
|
|
4127
|
+
* (today's behaviour, #3424), never "a live approval is destroyed".
|
|
4128
|
+
*
|
|
4129
|
+
* `recalled` is the finalisation because it is the platform's existing terminal
|
|
4130
|
+
* state for *a live request that ended without a decision*; the audit row names
|
|
4131
|
+
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4132
|
+
* release is never mistaken for a submitter's withdrawal.
|
|
4133
|
+
*/
|
|
4134
|
+
async releaseDeadRunRequests() {
|
|
4135
|
+
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4136
|
+
let rows = [];
|
|
4137
|
+
try {
|
|
4138
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4139
|
+
where: { status: "pending" },
|
|
4140
|
+
limit: 500,
|
|
4141
|
+
context: SYSTEM_CTX2
|
|
4142
|
+
}) ?? [];
|
|
4143
|
+
} catch (err) {
|
|
4144
|
+
this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
|
|
4145
|
+
error: err?.message ?? String(err)
|
|
4146
|
+
});
|
|
4147
|
+
return { scanned: 0, released: 0 };
|
|
4148
|
+
}
|
|
4149
|
+
let released = 0;
|
|
4150
|
+
for (const raw of rows) {
|
|
4151
|
+
try {
|
|
4152
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4153
|
+
if (!runId) continue;
|
|
4154
|
+
let status;
|
|
4155
|
+
try {
|
|
4156
|
+
const run = await this.automation.getRun(runId);
|
|
4157
|
+
status = typeof run?.status === "string" ? run.status : void 0;
|
|
4158
|
+
} catch (err) {
|
|
4159
|
+
this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
|
|
4160
|
+
request: raw?.id,
|
|
4161
|
+
run: runId,
|
|
4162
|
+
error: err?.message ?? String(err)
|
|
4163
|
+
});
|
|
4164
|
+
continue;
|
|
4165
|
+
}
|
|
4166
|
+
if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
|
|
4167
|
+
await this.abandonForDeadRun(raw, runId, status);
|
|
4168
|
+
released++;
|
|
4169
|
+
} catch (err) {
|
|
4170
|
+
this.logger?.warn?.("[approvals] dead-run release failed for request", {
|
|
4171
|
+
request: raw?.id,
|
|
4172
|
+
error: err?.message ?? String(err)
|
|
4173
|
+
});
|
|
4174
|
+
}
|
|
4175
|
+
}
|
|
4176
|
+
if (released > 0) {
|
|
4177
|
+
this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
|
|
4178
|
+
}
|
|
4179
|
+
return { scanned: rows.length, released };
|
|
4180
|
+
}
|
|
4181
|
+
/**
|
|
4182
|
+
* Finalise one pending request whose owning run is terminal. Mirrors the
|
|
4183
|
+
* shape of {@link recall} — audit row first (so a crash mid-release leaves a
|
|
4184
|
+
* trace of the intent), then the status transition, approver-index sync and
|
|
4185
|
+
* the optional status-field mirror. No resume/cancel of the run: it is already
|
|
4186
|
+
* terminal, which is precisely why we are here.
|
|
4187
|
+
*/
|
|
4188
|
+
async abandonForDeadRun(raw, runId, runStatus) {
|
|
4189
|
+
const org = raw.organization_id ?? null;
|
|
4190
|
+
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
4191
|
+
const now = this.clock.now().toISOString();
|
|
4192
|
+
await this.engine.insert("sys_approval_action", {
|
|
4193
|
+
id: uid("aact"),
|
|
4194
|
+
request_id: raw.id,
|
|
4195
|
+
organization_id: org,
|
|
4196
|
+
step_name: nodeId,
|
|
4197
|
+
step_index: 0,
|
|
4198
|
+
action: "recall",
|
|
4199
|
+
actor_id: DEAD_RUN_ACTOR_ID,
|
|
4200
|
+
comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
|
|
4201
|
+
created_at: now
|
|
4202
|
+
}, { context: SYSTEM_CTX2 });
|
|
4203
|
+
await this.engine.update("sys_approval_request", {
|
|
4204
|
+
id: raw.id,
|
|
4205
|
+
status: "recalled",
|
|
4206
|
+
pending_approvers: null,
|
|
4207
|
+
completed_at: now,
|
|
4208
|
+
updated_at: now
|
|
4209
|
+
}, { context: SYSTEM_CTX2 });
|
|
4210
|
+
await this.syncApproverIndex(raw.id, [], org, now);
|
|
4211
|
+
const config = parseJson(
|
|
4212
|
+
raw.node_config_json,
|
|
4213
|
+
{ approvers: [], behavior: "first_response" }
|
|
4214
|
+
);
|
|
4215
|
+
if (config.approvalStatusField) {
|
|
4216
|
+
await this.mirrorStatusField(
|
|
4217
|
+
raw.object_name,
|
|
4218
|
+
raw.record_id,
|
|
4219
|
+
config.approvalStatusField,
|
|
4220
|
+
"recalled",
|
|
4221
|
+
null
|
|
4222
|
+
);
|
|
4223
|
+
}
|
|
4224
|
+
this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
|
|
4225
|
+
request: raw.id,
|
|
4226
|
+
run: runId,
|
|
4227
|
+
runStatus,
|
|
4228
|
+
object: raw.object_name,
|
|
4229
|
+
record: raw.record_id
|
|
4230
|
+
});
|
|
4231
|
+
}
|
|
2890
4232
|
/** Execute the configured escalation action for one overdue request. */
|
|
2891
4233
|
async escalateRequest(raw, esc2) {
|
|
2892
4234
|
const action = esc2.action ?? "notify";
|
|
@@ -2912,13 +4254,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2912
4254
|
actor_id: SLA_ACTOR_ID,
|
|
2913
4255
|
comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
|
|
2914
4256
|
created_at: now
|
|
2915
|
-
}, { context:
|
|
4257
|
+
}, { context: SYSTEM_CTX2 });
|
|
2916
4258
|
if (action === "reassign" && escalatees.length) {
|
|
2917
4259
|
await this.engine.update("sys_approval_request", {
|
|
2918
4260
|
id: raw.id,
|
|
2919
4261
|
pending_approvers: escalatees.join(","),
|
|
2920
4262
|
updated_at: now
|
|
2921
|
-
}, { context:
|
|
4263
|
+
}, { context: SYSTEM_CTX2 });
|
|
2922
4264
|
await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
|
|
2923
4265
|
await this.notify({
|
|
2924
4266
|
topic: "approval.escalated",
|
|
@@ -2936,7 +4278,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2936
4278
|
decision: action === "auto_approve" ? "approve" : "reject",
|
|
2937
4279
|
actorId: SLA_ACTOR_ID,
|
|
2938
4280
|
comment: "SLA escalation"
|
|
2939
|
-
},
|
|
4281
|
+
}, SYSTEM_CTX2);
|
|
2940
4282
|
} else {
|
|
2941
4283
|
await this.notify({
|
|
2942
4284
|
topic: "approval.sla_breached",
|
|
@@ -3004,7 +4346,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3004
4346
|
where: { id: { $in: targets } },
|
|
3005
4347
|
fields: ["id", "name", "email"],
|
|
3006
4348
|
limit: targets.length,
|
|
3007
|
-
context:
|
|
4349
|
+
context: SYSTEM_CTX2
|
|
3008
4350
|
});
|
|
3009
4351
|
for (const u of users ?? []) {
|
|
3010
4352
|
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
@@ -3018,7 +4360,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3018
4360
|
where: { email: { $in: unresolvedEmails } },
|
|
3019
4361
|
fields: ["email", "name"],
|
|
3020
4362
|
limit: unresolvedEmails.length,
|
|
3021
|
-
context:
|
|
4363
|
+
context: SYSTEM_CTX2
|
|
3022
4364
|
});
|
|
3023
4365
|
for (const u of users ?? []) {
|
|
3024
4366
|
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
@@ -3044,11 +4386,32 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3044
4386
|
return [];
|
|
3045
4387
|
}
|
|
3046
4388
|
}
|
|
4389
|
+
/**
|
|
4390
|
+
* Field key → display label for an object's schema. Lets the inbox summary
|
|
4391
|
+
* show a human field name ("考核状态") instead of a title-cased machine key
|
|
4392
|
+
* ("Assessment Status"). For a single-locale project the schema label already
|
|
4393
|
+
* IS the localized string; symmetric with `resolveDisplayField`/lookup
|
|
4394
|
+
* resolution that power `payload_display`.
|
|
4395
|
+
*/
|
|
4396
|
+
resolveFieldLabels(object) {
|
|
4397
|
+
try {
|
|
4398
|
+
const schema = this.engine.getSchema?.(object);
|
|
4399
|
+
const fields = schema?.fields ?? {};
|
|
4400
|
+
const out = {};
|
|
4401
|
+
for (const [key, f] of Object.entries(fields)) {
|
|
4402
|
+
if (f?.label) out[key] = String(f.label);
|
|
4403
|
+
}
|
|
4404
|
+
return out;
|
|
4405
|
+
} catch {
|
|
4406
|
+
return {};
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
3047
4409
|
/**
|
|
3048
4410
|
* Attach inbox display fields to rows so clients never render a raw
|
|
3049
4411
|
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
3050
|
-
* `pending_approver_names` (user-id approvers),
|
|
3051
|
-
* (lookup foreign keys in the snapshot → referenced record titles)
|
|
4412
|
+
* `pending_approver_names` (user-id approvers), `payload_display`
|
|
4413
|
+
* (lookup foreign keys in the snapshot → referenced record titles), and
|
|
4414
|
+
* `payload_labels` (snapshot field keys → the target object's field labels).
|
|
3052
4415
|
* Batched: one query per distinct object (target + referenced) plus one
|
|
3053
4416
|
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
3054
4417
|
* payload snapshot, and any failure leaves the field unset rather than
|
|
@@ -3080,7 +4443,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3080
4443
|
const recs = await this.engine.find(object, {
|
|
3081
4444
|
where: { id: { $in: ids } },
|
|
3082
4445
|
limit: ids.length,
|
|
3083
|
-
context:
|
|
4446
|
+
context: SYSTEM_CTX2
|
|
3084
4447
|
});
|
|
3085
4448
|
for (const rec of recs ?? []) {
|
|
3086
4449
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3090,9 +4453,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3090
4453
|
}
|
|
3091
4454
|
}
|
|
3092
4455
|
const lookupFieldsByObject = /* @__PURE__ */ new Map();
|
|
4456
|
+
const fieldLabelsByObject = /* @__PURE__ */ new Map();
|
|
3093
4457
|
for (const object of byObject.keys()) {
|
|
3094
4458
|
const lookups = this.resolveLookupFields(object);
|
|
3095
4459
|
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
4460
|
+
const labels = this.resolveFieldLabels(object);
|
|
4461
|
+
if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
|
|
3096
4462
|
}
|
|
3097
4463
|
const refIds = /* @__PURE__ */ new Map();
|
|
3098
4464
|
for (const r of rows) {
|
|
@@ -3118,7 +4484,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3118
4484
|
const recs = await this.engine.find(object, {
|
|
3119
4485
|
where: { id: { $in: ids } },
|
|
3120
4486
|
limit: ids.length,
|
|
3121
|
-
context:
|
|
4487
|
+
context: SYSTEM_CTX2
|
|
3122
4488
|
});
|
|
3123
4489
|
for (const rec of recs ?? []) {
|
|
3124
4490
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3159,6 +4525,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3159
4525
|
}
|
|
3160
4526
|
if (Object.keys(display).length) r.payload_display = display;
|
|
3161
4527
|
}
|
|
4528
|
+
const fieldLabels = fieldLabelsByObject.get(r.object_name);
|
|
4529
|
+
if (fieldLabels && r.payload && typeof r.payload === "object") {
|
|
4530
|
+
const labels = {};
|
|
4531
|
+
for (const key of Object.keys(r.payload)) {
|
|
4532
|
+
const l = fieldLabels[key];
|
|
4533
|
+
if (l) labels[key] = l;
|
|
4534
|
+
}
|
|
4535
|
+
if (Object.keys(labels).length) r.payload_labels = labels;
|
|
4536
|
+
}
|
|
3162
4537
|
}
|
|
3163
4538
|
}
|
|
3164
4539
|
// ── Pending-approver index (issue #1745) ─────────────────────
|
|
@@ -3174,12 +4549,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3174
4549
|
const existing = await this.engine.find("sys_approval_approver", {
|
|
3175
4550
|
where: { request_id: requestId },
|
|
3176
4551
|
limit: 500,
|
|
3177
|
-
context:
|
|
4552
|
+
context: SYSTEM_CTX2
|
|
3178
4553
|
});
|
|
3179
4554
|
const rows = Array.isArray(existing) ? existing : [];
|
|
3180
4555
|
for (const row of rows) {
|
|
3181
4556
|
if (desired.has(String(row.approver))) desired.delete(String(row.approver));
|
|
3182
|
-
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4557
|
+
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3183
4558
|
}
|
|
3184
4559
|
for (const approver of desired) {
|
|
3185
4560
|
await this.engine.insert("sys_approval_approver", {
|
|
@@ -3188,7 +4563,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3188
4563
|
approver,
|
|
3189
4564
|
organization_id: org,
|
|
3190
4565
|
created_at: now
|
|
3191
|
-
}, { context:
|
|
4566
|
+
}, { context: SYSTEM_CTX2 });
|
|
3192
4567
|
}
|
|
3193
4568
|
}
|
|
3194
4569
|
/**
|
|
@@ -3200,35 +4575,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3200
4575
|
async rebuildApproverIndex() {
|
|
3201
4576
|
const desired = /* @__PURE__ */ new Map();
|
|
3202
4577
|
const PAGE = 500;
|
|
3203
|
-
|
|
3204
|
-
|
|
3205
|
-
|
|
4578
|
+
const requests = keysetWalk(
|
|
4579
|
+
(q) => this.engine.find("sys_approval_request", {
|
|
4580
|
+
...q,
|
|
3206
4581
|
fields: ["id", "pending_approvers", "organization_id"],
|
|
3207
|
-
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
4582
|
+
context: SYSTEM_CTX2
|
|
4583
|
+
}),
|
|
4584
|
+
{ where: { status: "pending" }, pageSize: PAGE }
|
|
4585
|
+
);
|
|
4586
|
+
for await (const rows of requests.pages()) {
|
|
3212
4587
|
for (const r of rows) {
|
|
3213
4588
|
desired.set(String(r.id), {
|
|
3214
4589
|
approvers: new Set(csvSplit(r.pending_approvers)),
|
|
3215
4590
|
org: r.organization_id ?? null
|
|
3216
4591
|
});
|
|
3217
4592
|
}
|
|
3218
|
-
if (rows.length < PAGE) break;
|
|
3219
4593
|
}
|
|
3220
4594
|
const indexRows = [];
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
context: SYSTEM_CTX
|
|
3227
|
-
});
|
|
3228
|
-
const rows = Array.isArray(batch) ? batch : [];
|
|
3229
|
-
indexRows.push(...rows);
|
|
3230
|
-
if (rows.length < PAGE) break;
|
|
3231
|
-
}
|
|
4595
|
+
const index = keysetWalk(
|
|
4596
|
+
(q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
|
|
4597
|
+
{ pageSize: PAGE }
|
|
4598
|
+
);
|
|
4599
|
+
for await (const rows of index.pages()) indexRows.push(...rows);
|
|
3232
4600
|
let inserted = 0;
|
|
3233
4601
|
let deleted = 0;
|
|
3234
4602
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -3237,7 +4605,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3237
4605
|
const want = desired.get(reqId);
|
|
3238
4606
|
const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
|
|
3239
4607
|
if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
|
|
3240
|
-
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4608
|
+
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3241
4609
|
deleted++;
|
|
3242
4610
|
continue;
|
|
3243
4611
|
}
|
|
@@ -3254,7 +4622,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3254
4622
|
approver,
|
|
3255
4623
|
organization_id: want.org,
|
|
3256
4624
|
created_at: now
|
|
3257
|
-
}, { context:
|
|
4625
|
+
}, { context: SYSTEM_CTX2 });
|
|
3258
4626
|
inserted++;
|
|
3259
4627
|
}
|
|
3260
4628
|
}
|
|
@@ -3304,7 +4672,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3304
4672
|
where,
|
|
3305
4673
|
fields: ["request_id"],
|
|
3306
4674
|
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
3307
|
-
context:
|
|
4675
|
+
context: SYSTEM_CTX2
|
|
3308
4676
|
});
|
|
3309
4677
|
const list = Array.isArray(rows) ? rows : [];
|
|
3310
4678
|
if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
|
|
@@ -3315,6 +4683,88 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3315
4683
|
}
|
|
3316
4684
|
return [...new Set(list.map((r) => String(r.request_id)))];
|
|
3317
4685
|
}
|
|
4686
|
+
/**
|
|
4687
|
+
* The request ids this caller is a PARTICIPANT of, or `null` for a caller
|
|
4688
|
+
* who may see everything in scope (#3590).
|
|
4689
|
+
*
|
|
4690
|
+
* These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
|
|
4691
|
+
* approver-visibility rule spans several identity forms that RLS cannot model
|
|
4692
|
+
* cleanly, which is why it has to be expressed here. Until now only the
|
|
4693
|
+
* TENANT half of that rule was applied, so any authenticated user could read
|
|
4694
|
+
* any request in their tenant (and, once attachments derived their access
|
|
4695
|
+
* from the request, its files too). This adds the participant half.
|
|
4696
|
+
*
|
|
4697
|
+
* A participant is the submitter, a current approver, or someone who has
|
|
4698
|
+
* already acted on the request (a past approver whose slot has moved on, a
|
|
4699
|
+
* commenter). Admins with override authority keep the unrestricted view the
|
|
4700
|
+
* "all requests" console surface depends on.
|
|
4701
|
+
*
|
|
4702
|
+
* Keying on the concrete user id is sufficient rather than an approximation:
|
|
4703
|
+
* position/team/manager/field approvers are resolved to concrete user ids at
|
|
4704
|
+
* open time, and the `type:value` literal is only the fallback for a spec
|
|
4705
|
+
* that resolved to NOBODY — a slot no one can act on either way (`can_act`
|
|
4706
|
+
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
4707
|
+
* request from someone who could actually act on it.
|
|
4708
|
+
*/
|
|
4709
|
+
async visibleRequestIds(context, tenantOrg) {
|
|
4710
|
+
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
4711
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
4712
|
+
if (!uid2) return /* @__PURE__ */ new Set();
|
|
4713
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4714
|
+
const cap = _ApprovalService.APPROVER_INDEX_CAP;
|
|
4715
|
+
const add = (rows, key) => {
|
|
4716
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
4717
|
+
for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
|
|
4718
|
+
if (list.length >= cap) {
|
|
4719
|
+
this.logger?.warn?.(
|
|
4720
|
+
"[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
|
|
4721
|
+
{ cap, key }
|
|
4722
|
+
);
|
|
4723
|
+
}
|
|
4724
|
+
};
|
|
4725
|
+
try {
|
|
4726
|
+
for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
|
|
4727
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
4728
|
+
add(
|
|
4729
|
+
await this.engine.find("sys_approval_request", {
|
|
4730
|
+
where: { submitter_id: uid2, ...orgWhere },
|
|
4731
|
+
fields: ["id"],
|
|
4732
|
+
limit: cap,
|
|
4733
|
+
context: SYSTEM_CTX2
|
|
4734
|
+
}),
|
|
4735
|
+
"id"
|
|
4736
|
+
);
|
|
4737
|
+
add(
|
|
4738
|
+
await this.engine.find("sys_approval_action", {
|
|
4739
|
+
where: { actor_id: uid2 },
|
|
4740
|
+
fields: ["request_id"],
|
|
4741
|
+
limit: cap,
|
|
4742
|
+
context: SYSTEM_CTX2
|
|
4743
|
+
}),
|
|
4744
|
+
"request_id"
|
|
4745
|
+
);
|
|
4746
|
+
} catch (err) {
|
|
4747
|
+
this.logger?.warn?.("[approvals] participant-visibility probe failed", {
|
|
4748
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4749
|
+
});
|
|
4750
|
+
}
|
|
4751
|
+
return ids;
|
|
4752
|
+
}
|
|
4753
|
+
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
4754
|
+
applyVisibility(where, visible) {
|
|
4755
|
+
if (!visible) return true;
|
|
4756
|
+
if (visible.size === 0) return false;
|
|
4757
|
+
let allowed = [...visible];
|
|
4758
|
+
const current = where.id;
|
|
4759
|
+
if (typeof current === "string") allowed = allowed.filter((x) => x === current);
|
|
4760
|
+
else if (current && typeof current === "object" && Array.isArray(current.$in)) {
|
|
4761
|
+
const set = new Set(current.$in.map((v) => String(v)));
|
|
4762
|
+
allowed = allowed.filter((x) => set.has(x));
|
|
4763
|
+
}
|
|
4764
|
+
if (allowed.length === 0) return false;
|
|
4765
|
+
where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
|
|
4766
|
+
return true;
|
|
4767
|
+
}
|
|
3318
4768
|
async listRequests(filter, context) {
|
|
3319
4769
|
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
3320
4770
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
@@ -3323,10 +4773,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3323
4773
|
if (ids.length === 0) return [];
|
|
3324
4774
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3325
4775
|
}
|
|
4776
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
|
|
3326
4777
|
const findOpts = {
|
|
3327
4778
|
where,
|
|
3328
4779
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
3329
|
-
context:
|
|
4780
|
+
context: SYSTEM_CTX2
|
|
3330
4781
|
};
|
|
3331
4782
|
if (filter?.limit != null || filter?.offset != null) {
|
|
3332
4783
|
findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
|
|
@@ -3348,10 +4799,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3348
4799
|
if (ids.length === 0) return 0;
|
|
3349
4800
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3350
4801
|
}
|
|
4802
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
|
|
3351
4803
|
const countFn = this.engine.count;
|
|
3352
4804
|
if (typeof countFn === "function") {
|
|
3353
4805
|
try {
|
|
3354
|
-
const n = await countFn.call(this.engine, "sys_approval_request", { where, context:
|
|
4806
|
+
const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
|
|
3355
4807
|
if (typeof n === "number") return n;
|
|
3356
4808
|
} catch {
|
|
3357
4809
|
}
|
|
@@ -3360,11 +4812,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3360
4812
|
where,
|
|
3361
4813
|
fields: ["id"],
|
|
3362
4814
|
limit: ids ? Math.max(500, ids.length) : 500,
|
|
3363
|
-
context:
|
|
4815
|
+
context: SYSTEM_CTX2
|
|
3364
4816
|
});
|
|
3365
4817
|
return Array.isArray(rows) ? rows.length : 0;
|
|
3366
4818
|
}
|
|
4819
|
+
/**
|
|
4820
|
+
* Read the request a write path just changed, to echo back as its result.
|
|
4821
|
+
*
|
|
4822
|
+
* NOT participant-gated (#3590), deliberately: the operation authorized
|
|
4823
|
+
* itself by its own rule before writing, so re-asking "may you see this?"
|
|
4824
|
+
* for the echo answers a question that has already been settled — and would
|
|
4825
|
+
* answer it WRONG for a caller context that carries no `userId` (a
|
|
4826
|
+
* flow-driven resume, a service-to-service call), turning a successful write
|
|
4827
|
+
* into a `null` result. Gating belongs on the read API, not on an
|
|
4828
|
+
* operation's own return value.
|
|
4829
|
+
*/
|
|
4830
|
+
async readBackRequest(requestId, context) {
|
|
4831
|
+
return this.loadRequest(requestId, context, false);
|
|
4832
|
+
}
|
|
3367
4833
|
async getRequest(requestId, context) {
|
|
4834
|
+
return this.loadRequest(requestId, context, true);
|
|
4835
|
+
}
|
|
4836
|
+
async loadRequest(requestId, context, enforceVisibility) {
|
|
3368
4837
|
if (!requestId) return null;
|
|
3369
4838
|
const where = { id: requestId };
|
|
3370
4839
|
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
@@ -3372,9 +4841,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3372
4841
|
const rows = await this.engine.find("sys_approval_request", {
|
|
3373
4842
|
where,
|
|
3374
4843
|
limit: 1,
|
|
3375
|
-
context:
|
|
4844
|
+
context: SYSTEM_CTX2
|
|
3376
4845
|
});
|
|
3377
4846
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
4847
|
+
if (enforceVisibility) {
|
|
4848
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
|
|
4849
|
+
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
4850
|
+
}
|
|
3378
4851
|
const row = rowFromRequest(rows[0]);
|
|
3379
4852
|
await this.enrichRows([row]);
|
|
3380
4853
|
await this.attachFlowSteps(row);
|
|
@@ -3401,7 +4874,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3401
4874
|
const acts = await this.engine.find("sys_approval_action", {
|
|
3402
4875
|
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3403
4876
|
limit: 1e3,
|
|
3404
|
-
context:
|
|
4877
|
+
context: SYSTEM_CTX2
|
|
3405
4878
|
});
|
|
3406
4879
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3407
4880
|
const snapshot = cfg?.__approverGroups;
|
|
@@ -3422,6 +4895,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3422
4895
|
});
|
|
3423
4896
|
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3424
4897
|
progress.need = progress.groups.length;
|
|
4898
|
+
const pendingGroups = {};
|
|
4899
|
+
for (const a of row.pending_approvers ?? []) {
|
|
4900
|
+
const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
|
|
4901
|
+
if (named.length) pendingGroups[a] = named;
|
|
4902
|
+
}
|
|
4903
|
+
if (Object.keys(pendingGroups).length) {
|
|
4904
|
+
row.pending_approver_groups = pendingGroups;
|
|
4905
|
+
}
|
|
3425
4906
|
}
|
|
3426
4907
|
row.decision_progress = progress;
|
|
3427
4908
|
} catch {
|
|
@@ -3433,8 +4914,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3433
4914
|
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
3434
4915
|
* still `pending` (position/team/manager approvers are already resolved to
|
|
3435
4916
|
* concrete user ids at open time, so a plain membership test is faithful).
|
|
3436
|
-
* `is_submitter` is a straight owner check.
|
|
3437
|
-
*
|
|
4917
|
+
* `is_submitter` is a straight owner check. `can_override` (#3424) is true for
|
|
4918
|
+
* a platform/tenant admin on a PENDING request — the recovery path for an
|
|
4919
|
+
* approval routed to an unstaffed position or to approvers who have all left;
|
|
4920
|
+
* clients OR it into the decision actions' `visible` gate so an admin can act
|
|
4921
|
+
* even when they hold no slot. System/tokenless contexts get a both-false
|
|
4922
|
+
* `can_act`/`is_submitter` block (system gets `can_override` too — it may act
|
|
4923
|
+
* on anything). Cheap + synchronous — safe on list reads.
|
|
3438
4924
|
*/
|
|
3439
4925
|
attachViewers(rows, context) {
|
|
3440
4926
|
const uid2 = context?.userId != null ? String(context.userId) : null;
|
|
@@ -3442,7 +4928,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3442
4928
|
const pending = row.pending_approvers ?? [];
|
|
3443
4929
|
row.viewer = {
|
|
3444
4930
|
can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
|
|
3445
|
-
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
|
|
4931
|
+
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
|
|
4932
|
+
can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
|
|
3446
4933
|
};
|
|
3447
4934
|
}
|
|
3448
4935
|
}
|
|
@@ -3489,18 +4976,52 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3489
4976
|
where: { request_id: requestId },
|
|
3490
4977
|
limit: 500,
|
|
3491
4978
|
orderBy: [{ field: "created_at", order: "asc" }],
|
|
3492
|
-
context:
|
|
4979
|
+
context: SYSTEM_CTX2
|
|
3493
4980
|
});
|
|
3494
4981
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
3495
4982
|
const names = await this.resolveUserNames(
|
|
3496
|
-
actions.
|
|
4983
|
+
actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
|
|
3497
4984
|
);
|
|
3498
4985
|
for (const a of actions) {
|
|
3499
4986
|
const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
|
|
3500
4987
|
if (n) a.actor_name = n;
|
|
4988
|
+
const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
|
|
4989
|
+
if (fromName) a.reassign_from_name = fromName;
|
|
4990
|
+
const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
|
|
4991
|
+
if (toName) a.reassign_to_name = toName;
|
|
3501
4992
|
}
|
|
3502
4993
|
return actions;
|
|
3503
4994
|
}
|
|
4995
|
+
/**
|
|
4996
|
+
* `IFileAccessDelegate` — may this caller download a decision attachment?
|
|
4997
|
+
* (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
|
|
4998
|
+
*
|
|
4999
|
+
* A file referenced by `sys_approval_action.attachments` is owned by that
|
|
5000
|
+
* audit row, so the storage service would otherwise authorize the download by
|
|
5001
|
+
* testing whether the caller can READ the row. It cannot: `sys_approval_action`
|
|
5002
|
+
* is deliberately closed to ordinary approver positions, so that test denies
|
|
5003
|
+
* the very approver the attachment was filed for.
|
|
5004
|
+
*
|
|
5005
|
+
* The rule that actually governs seeing a decision is the one `listActions`
|
|
5006
|
+
* applies — can the caller see the PARENT REQUEST? — so this reuses it
|
|
5007
|
+
* exactly, rather than inventing a second, looser rule for the bytes. Fails
|
|
5008
|
+
* closed on any error.
|
|
5009
|
+
*/
|
|
5010
|
+
async authorizeFileRead(actionId, context) {
|
|
5011
|
+
if (!actionId) return false;
|
|
5012
|
+
try {
|
|
5013
|
+
const rows = await this.engine.find("sys_approval_action", {
|
|
5014
|
+
where: { id: actionId },
|
|
5015
|
+
limit: 1,
|
|
5016
|
+
context: SYSTEM_CTX2
|
|
5017
|
+
});
|
|
5018
|
+
const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
|
|
5019
|
+
if (!requestId) return false;
|
|
5020
|
+
return !!await this.getRequest(String(requestId), context);
|
|
5021
|
+
} catch {
|
|
5022
|
+
return false;
|
|
5023
|
+
}
|
|
5024
|
+
}
|
|
3504
5025
|
};
|
|
3505
5026
|
/** Window the approver-index probe — pending queues live far below this. */
|
|
3506
5027
|
_ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
@@ -3695,6 +5216,8 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
3695
5216
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3696
5217
|
const pending = await pendingRequestFor(engine, object, id);
|
|
3697
5218
|
if (!pending) return;
|
|
5219
|
+
const writerRun = ctx?.provenance?.flowRunId;
|
|
5220
|
+
if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
|
|
3698
5221
|
const config = parseJson2(pending.node_config_json, {});
|
|
3699
5222
|
if (config?.lockRecord === false) return;
|
|
3700
5223
|
const mirror = config?.approvalStatusField;
|
|
@@ -3751,7 +5274,22 @@ import {
|
|
|
3751
5274
|
getApprovalNodeConfigJsonSchema,
|
|
3752
5275
|
APPROVAL_NODE_TYPE
|
|
3753
5276
|
} from "@objectstack/spec/automation";
|
|
3754
|
-
var
|
|
5277
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5278
|
+
function nestVariables(variables) {
|
|
5279
|
+
const vars = {};
|
|
5280
|
+
for (const [key, value] of variables) {
|
|
5281
|
+
const segs = key.split(".");
|
|
5282
|
+
let cursor = vars;
|
|
5283
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
5284
|
+
if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
|
|
5285
|
+
cursor[segs[i]] = {};
|
|
5286
|
+
}
|
|
5287
|
+
cursor = cursor[segs[i]];
|
|
5288
|
+
}
|
|
5289
|
+
cursor[segs[segs.length - 1]] = value;
|
|
5290
|
+
}
|
|
5291
|
+
return vars;
|
|
5292
|
+
}
|
|
3755
5293
|
function registerApprovalNode(automation, service, logger) {
|
|
3756
5294
|
automation.registerNodeExecutor({
|
|
3757
5295
|
type: APPROVAL_NODE_TYPE,
|
|
@@ -3767,6 +5305,15 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3767
5305
|
// Human decision: the run suspends here awaiting an external reply.
|
|
3768
5306
|
supportsPause: true,
|
|
3769
5307
|
isAsync: true,
|
|
5308
|
+
// #3801: this pause is NOT resumable through the generic run-resume
|
|
5309
|
+
// route. Continuing an approval is a side effect of a DECISION, and the
|
|
5310
|
+
// decision is the thing that must be authorized (the approver slate),
|
|
5311
|
+
// recorded (`sys_approval_action`) and mirrored (the status field) —
|
|
5312
|
+
// all of which lives in `ApprovalService.decide`. The engine now refuses
|
|
5313
|
+
// any resume of an approval suspension that does not carry the service's
|
|
5314
|
+
// in-process marker, so "decide via the approvals API, never a raw engine
|
|
5315
|
+
// resume" is enforced rather than merely documented.
|
|
5316
|
+
resumeAuthority: "service",
|
|
3770
5317
|
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
3771
5318
|
// Studio flow designer renders the Approval property form from the engine
|
|
3772
5319
|
// rather than a hardcoded client form — the engine owns the shape.
|
|
@@ -3800,13 +5347,28 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3800
5347
|
nodeLabel: typeof node.label === "string" ? node.label : void 0,
|
|
3801
5348
|
submitterId: context?.userId ?? null,
|
|
3802
5349
|
record,
|
|
3803
|
-
organizationId: context?.organizationId ?? context?.tenantId ?? null
|
|
5350
|
+
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
5351
|
+
// #3447 P2: flow variables (nested, as CEL conditions see them) — the
|
|
5352
|
+
// `vars.*` root for `expression` approvers; `record` above doubles as
|
|
5353
|
+
// their `trigger.*` snapshot root.
|
|
5354
|
+
variables: nestVariables(variables)
|
|
3804
5355
|
}, {
|
|
3805
|
-
...
|
|
5356
|
+
...SYSTEM_CTX3,
|
|
3806
5357
|
userId: context?.userId,
|
|
3807
5358
|
organizationId: context?.organizationId,
|
|
3808
5359
|
tenantId: context?.tenantId
|
|
3809
5360
|
});
|
|
5361
|
+
if ("autoApproved" in request) {
|
|
5362
|
+
logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
|
|
5363
|
+
node: node.id,
|
|
5364
|
+
run: String(runId)
|
|
5365
|
+
});
|
|
5366
|
+
return {
|
|
5367
|
+
success: true,
|
|
5368
|
+
branchLabel: "approve",
|
|
5369
|
+
output: { decision: "approve", autoApproved: true }
|
|
5370
|
+
};
|
|
5371
|
+
}
|
|
3810
5372
|
logger?.info?.("[approvals] approval node suspended run", {
|
|
3811
5373
|
node: node.id,
|
|
3812
5374
|
request: request.id,
|
|
@@ -3892,7 +5454,21 @@ var ApprovalsServicePlugin = class {
|
|
|
3892
5454
|
this.service = new ApprovalService({
|
|
3893
5455
|
engine,
|
|
3894
5456
|
logger: ctx.logger,
|
|
3895
|
-
publicBaseUrl: this.options.publicBaseUrl
|
|
5457
|
+
publicBaseUrl: this.options.publicBaseUrl,
|
|
5458
|
+
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5459
|
+
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5460
|
+
// service resolves its posture during its own start, which may not have
|
|
5461
|
+
// run yet; an unresolvable posture reads as "unknown" and the guard
|
|
5462
|
+
// stands down rather than refusing a legitimate flow on a minimal stack.
|
|
5463
|
+
tenancyPosture: () => {
|
|
5464
|
+
try {
|
|
5465
|
+
const tenancy = ctx.getService("tenancy");
|
|
5466
|
+
const posture = tenancy?.posture;
|
|
5467
|
+
return typeof posture === "string" && posture ? posture : void 0;
|
|
5468
|
+
} catch {
|
|
5469
|
+
return void 0;
|
|
5470
|
+
}
|
|
5471
|
+
}
|
|
3896
5472
|
});
|
|
3897
5473
|
if (!this.options.disableAutoHooks) {
|
|
3898
5474
|
try {
|
|
@@ -3918,12 +5494,23 @@ var ApprovalsServicePlugin = class {
|
|
|
3918
5494
|
if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
|
|
3919
5495
|
const svc = this.service;
|
|
3920
5496
|
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
|
|
3921
|
-
|
|
3922
|
-
await
|
|
3923
|
-
|
|
5497
|
+
const sweep = async () => {
|
|
5498
|
+
const results = await Promise.allSettled([
|
|
5499
|
+
svc.runEscalations(),
|
|
5500
|
+
svc.releaseDeadRunRequests()
|
|
5501
|
+
]);
|
|
5502
|
+
for (const r of results) {
|
|
5503
|
+
if (r.status === "rejected") {
|
|
5504
|
+
ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
|
|
5505
|
+
error: r.reason?.message ?? String(r.reason)
|
|
5506
|
+
});
|
|
5507
|
+
}
|
|
5508
|
+
}
|
|
5509
|
+
};
|
|
5510
|
+
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
3924
5511
|
this.escalationJobScheduled = true;
|
|
3925
|
-
void
|
|
3926
|
-
ctx.logger.warn?.("[approvals] boot
|
|
5512
|
+
void sweep().catch((err) => {
|
|
5513
|
+
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
3927
5514
|
});
|
|
3928
5515
|
ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
|
|
3929
5516
|
} catch {
|