@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.js
CHANGED
|
@@ -66,7 +66,8 @@ var init_en_objects_generated = __esm({
|
|
|
66
66
|
pending: "pending",
|
|
67
67
|
approved: "approved",
|
|
68
68
|
rejected: "rejected",
|
|
69
|
-
recalled: "recalled"
|
|
69
|
+
recalled: "recalled",
|
|
70
|
+
returned: "returned"
|
|
70
71
|
}
|
|
71
72
|
},
|
|
72
73
|
current_step: {
|
|
@@ -108,7 +109,11 @@ var init_en_objects_generated = __esm({
|
|
|
108
109
|
},
|
|
109
110
|
_views: {
|
|
110
111
|
my_pending: {
|
|
111
|
-
label: "My Pending"
|
|
112
|
+
label: "My Pending",
|
|
113
|
+
emptyState: {
|
|
114
|
+
title: "No pending approvals",
|
|
115
|
+
message: "You're all caught up."
|
|
116
|
+
}
|
|
112
117
|
},
|
|
113
118
|
submitted_by_me: {
|
|
114
119
|
label: "I Submitted"
|
|
@@ -119,6 +124,92 @@ var init_en_objects_generated = __esm({
|
|
|
119
124
|
all_requests: {
|
|
120
125
|
label: "All"
|
|
121
126
|
}
|
|
127
|
+
},
|
|
128
|
+
_actions: {
|
|
129
|
+
approval_approve: {
|
|
130
|
+
label: "Approve",
|
|
131
|
+
successMessage: "Approved.",
|
|
132
|
+
params: {
|
|
133
|
+
comment: {
|
|
134
|
+
label: "Comment"
|
|
135
|
+
},
|
|
136
|
+
attachments: {
|
|
137
|
+
label: "Attachments"
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
approval_reject: {
|
|
142
|
+
label: "Reject",
|
|
143
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
144
|
+
successMessage: "Rejected.",
|
|
145
|
+
params: {
|
|
146
|
+
comment: {
|
|
147
|
+
label: "Comment"
|
|
148
|
+
},
|
|
149
|
+
attachments: {
|
|
150
|
+
label: "Attachments"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
approval_reassign: {
|
|
155
|
+
label: "Reassign",
|
|
156
|
+
successMessage: "Reassigned.",
|
|
157
|
+
params: {
|
|
158
|
+
to: {
|
|
159
|
+
label: "New approver",
|
|
160
|
+
helpText: "User to hand this step to"
|
|
161
|
+
},
|
|
162
|
+
comment: {
|
|
163
|
+
label: "Comment"
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
approval_send_back: {
|
|
168
|
+
label: "Send back",
|
|
169
|
+
successMessage: "Sent back for revision.",
|
|
170
|
+
params: {
|
|
171
|
+
comment: {
|
|
172
|
+
label: "Reason"
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
approval_request_info: {
|
|
177
|
+
label: "Request info",
|
|
178
|
+
successMessage: "Information requested.",
|
|
179
|
+
params: {
|
|
180
|
+
comment: {
|
|
181
|
+
label: "What do you need?"
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
approval_remind: {
|
|
186
|
+
label: "Send reminder",
|
|
187
|
+
successMessage: "Reminder sent.",
|
|
188
|
+
params: {
|
|
189
|
+
comment: {
|
|
190
|
+
label: "Note"
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
},
|
|
194
|
+
approval_recall: {
|
|
195
|
+
label: "Recall",
|
|
196
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
197
|
+
successMessage: "Recalled.",
|
|
198
|
+
params: {
|
|
199
|
+
comment: {
|
|
200
|
+
label: "Comment"
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
},
|
|
204
|
+
approval_resubmit: {
|
|
205
|
+
label: "Resubmit",
|
|
206
|
+
successMessage: "Resubmitted.",
|
|
207
|
+
params: {
|
|
208
|
+
comment: {
|
|
209
|
+
label: "What changed?"
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
122
213
|
}
|
|
123
214
|
},
|
|
124
215
|
sys_approval_action: {
|
|
@@ -150,7 +241,14 @@ var init_en_objects_generated = __esm({
|
|
|
150
241
|
approve: "approve",
|
|
151
242
|
reject: "reject",
|
|
152
243
|
recall: "recall",
|
|
153
|
-
escalate: "escalate"
|
|
244
|
+
escalate: "escalate",
|
|
245
|
+
reassign: "reassign",
|
|
246
|
+
remind: "remind",
|
|
247
|
+
request_info: "request_info",
|
|
248
|
+
comment: "comment",
|
|
249
|
+
revise: "revise",
|
|
250
|
+
resubmit: "resubmit",
|
|
251
|
+
ooo_substitute: "ooo_substitute"
|
|
154
252
|
}
|
|
155
253
|
},
|
|
156
254
|
actor_id: {
|
|
@@ -159,13 +257,29 @@ var init_en_objects_generated = __esm({
|
|
|
159
257
|
comment: {
|
|
160
258
|
label: "Comment"
|
|
161
259
|
},
|
|
260
|
+
reassign_from: {
|
|
261
|
+
label: "Reassigned From",
|
|
262
|
+
help: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
263
|
+
},
|
|
264
|
+
reassign_to: {
|
|
265
|
+
label: "Reassigned To",
|
|
266
|
+
help: "User who received the pending-approver slot (reassign actions only)"
|
|
267
|
+
},
|
|
268
|
+
attachments: {
|
|
269
|
+
label: "Attachments",
|
|
270
|
+
help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
271
|
+
},
|
|
162
272
|
created_at: {
|
|
163
273
|
label: "Created At"
|
|
164
274
|
}
|
|
165
275
|
},
|
|
166
276
|
_views: {
|
|
167
277
|
recent: {
|
|
168
|
-
label: "Recent"
|
|
278
|
+
label: "Recent",
|
|
279
|
+
emptyState: {
|
|
280
|
+
title: "No approval actions yet",
|
|
281
|
+
message: "Actions are logged automatically when approvals progress."
|
|
282
|
+
}
|
|
169
283
|
},
|
|
170
284
|
by_actor: {
|
|
171
285
|
label: "By Actor"
|
|
@@ -269,7 +383,8 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
269
383
|
pending: "\u5F85\u5904\u7406",
|
|
270
384
|
approved: "\u5DF2\u6279\u51C6",
|
|
271
385
|
rejected: "\u5DF2\u62D2\u7EDD",
|
|
272
|
-
recalled: "\u5DF2\u64A4\u56DE"
|
|
386
|
+
recalled: "\u5DF2\u64A4\u56DE",
|
|
387
|
+
returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
|
|
273
388
|
}
|
|
274
389
|
},
|
|
275
390
|
current_step: {
|
|
@@ -311,7 +426,11 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
311
426
|
},
|
|
312
427
|
_views: {
|
|
313
428
|
my_pending: {
|
|
314
|
-
label: "\u6211\u7684\u5F85\u529E"
|
|
429
|
+
label: "\u6211\u7684\u5F85\u529E",
|
|
430
|
+
emptyState: {
|
|
431
|
+
title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
|
|
432
|
+
message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
|
|
433
|
+
}
|
|
315
434
|
},
|
|
316
435
|
submitted_by_me: {
|
|
317
436
|
label: "\u6211\u63D0\u4EA4\u7684"
|
|
@@ -322,6 +441,92 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
322
441
|
all_requests: {
|
|
323
442
|
label: "\u5168\u90E8"
|
|
324
443
|
}
|
|
444
|
+
},
|
|
445
|
+
_actions: {
|
|
446
|
+
approval_approve: {
|
|
447
|
+
label: "\u901A\u8FC7",
|
|
448
|
+
successMessage: "\u5DF2\u901A\u8FC7\u3002",
|
|
449
|
+
params: {
|
|
450
|
+
comment: {
|
|
451
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
452
|
+
},
|
|
453
|
+
attachments: {
|
|
454
|
+
label: "\u9644\u4EF6"
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
approval_reject: {
|
|
459
|
+
label: "\u62D2\u7EDD",
|
|
460
|
+
confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
|
|
461
|
+
successMessage: "\u5DF2\u62D2\u7EDD\u3002",
|
|
462
|
+
params: {
|
|
463
|
+
comment: {
|
|
464
|
+
label: "\u5BA1\u6279\u610F\u89C1"
|
|
465
|
+
},
|
|
466
|
+
attachments: {
|
|
467
|
+
label: "\u9644\u4EF6"
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
},
|
|
471
|
+
approval_reassign: {
|
|
472
|
+
label: "\u8F6C\u7B7E",
|
|
473
|
+
successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
|
|
474
|
+
params: {
|
|
475
|
+
to: {
|
|
476
|
+
label: "\u65B0\u5BA1\u6279\u4EBA",
|
|
477
|
+
helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
|
|
478
|
+
},
|
|
479
|
+
comment: {
|
|
480
|
+
label: "\u5907\u6CE8"
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
approval_send_back: {
|
|
485
|
+
label: "\u9000\u56DE\u4FEE\u6539",
|
|
486
|
+
successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
|
|
487
|
+
params: {
|
|
488
|
+
comment: {
|
|
489
|
+
label: "\u539F\u56E0"
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
approval_request_info: {
|
|
494
|
+
label: "\u9000\u56DE\u8865\u5145",
|
|
495
|
+
successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
|
|
496
|
+
params: {
|
|
497
|
+
comment: {
|
|
498
|
+
label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
},
|
|
502
|
+
approval_remind: {
|
|
503
|
+
label: "\u50AC\u529E",
|
|
504
|
+
successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
|
|
505
|
+
params: {
|
|
506
|
+
comment: {
|
|
507
|
+
label: "\u5907\u6CE8"
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
approval_recall: {
|
|
512
|
+
label: "\u64A4\u56DE",
|
|
513
|
+
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",
|
|
514
|
+
successMessage: "\u5DF2\u64A4\u56DE\u3002",
|
|
515
|
+
params: {
|
|
516
|
+
comment: {
|
|
517
|
+
label: "\u5907\u6CE8"
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
},
|
|
521
|
+
approval_resubmit: {
|
|
522
|
+
label: "\u91CD\u65B0\u63D0\u4EA4",
|
|
523
|
+
successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
|
|
524
|
+
params: {
|
|
525
|
+
comment: {
|
|
526
|
+
label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
325
530
|
}
|
|
326
531
|
},
|
|
327
532
|
sys_approval_action: {
|
|
@@ -353,7 +558,14 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
353
558
|
approve: "\u6279\u51C6",
|
|
354
559
|
reject: "\u62D2\u7EDD",
|
|
355
560
|
recall: "\u64A4\u56DE",
|
|
356
|
-
escalate: "\u5347\u7EA7"
|
|
561
|
+
escalate: "\u5347\u7EA7",
|
|
562
|
+
reassign: "\u8F6C\u7B7E",
|
|
563
|
+
remind: "\u50AC\u529E",
|
|
564
|
+
request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
|
|
565
|
+
comment: "\u8BC4\u8BBA",
|
|
566
|
+
revise: "\u9000\u56DE\u4FEE\u6539",
|
|
567
|
+
resubmit: "\u91CD\u65B0\u63D0\u4EA4",
|
|
568
|
+
ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
|
|
357
569
|
}
|
|
358
570
|
},
|
|
359
571
|
actor_id: {
|
|
@@ -362,13 +574,29 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
362
574
|
comment: {
|
|
363
575
|
label: "\u8BC4\u8BBA"
|
|
364
576
|
},
|
|
577
|
+
reassign_from: {
|
|
578
|
+
label: "\u8F6C\u51FA\u4EBA",
|
|
579
|
+
help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
580
|
+
},
|
|
581
|
+
reassign_to: {
|
|
582
|
+
label: "\u8F6C\u5165\u4EBA",
|
|
583
|
+
help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
|
|
584
|
+
},
|
|
585
|
+
attachments: {
|
|
586
|
+
label: "\u9644\u4EF6",
|
|
587
|
+
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"
|
|
588
|
+
},
|
|
365
589
|
created_at: {
|
|
366
590
|
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
367
591
|
}
|
|
368
592
|
},
|
|
369
593
|
_views: {
|
|
370
594
|
recent: {
|
|
371
|
-
label: "\u6700\u8FD1"
|
|
595
|
+
label: "\u6700\u8FD1",
|
|
596
|
+
emptyState: {
|
|
597
|
+
title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
|
|
598
|
+
message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
|
|
599
|
+
}
|
|
372
600
|
},
|
|
373
601
|
by_actor: {
|
|
374
602
|
label: "\u6309\u6267\u884C\u4EBA"
|
|
@@ -472,7 +700,8 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
472
700
|
pending: "\u4FDD\u7559\u4E2D",
|
|
473
701
|
approved: "\u627F\u8A8D\u6E08\u307F",
|
|
474
702
|
rejected: "\u5374\u4E0B\u6E08\u307F",
|
|
475
|
-
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
|
|
703
|
+
recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
|
|
704
|
+
returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
|
|
476
705
|
}
|
|
477
706
|
},
|
|
478
707
|
current_step: {
|
|
@@ -514,7 +743,11 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
514
743
|
},
|
|
515
744
|
_views: {
|
|
516
745
|
my_pending: {
|
|
517
|
-
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
|
|
746
|
+
label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
|
|
747
|
+
emptyState: {
|
|
748
|
+
title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
|
|
749
|
+
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"
|
|
750
|
+
}
|
|
518
751
|
},
|
|
519
752
|
submitted_by_me: {
|
|
520
753
|
label: "\u81EA\u5206\u304C\u9001\u4FE1"
|
|
@@ -525,6 +758,92 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
525
758
|
all_requests: {
|
|
526
759
|
label: "\u3059\u3079\u3066"
|
|
527
760
|
}
|
|
761
|
+
},
|
|
762
|
+
_actions: {
|
|
763
|
+
approval_approve: {
|
|
764
|
+
label: "\u627F\u8A8D",
|
|
765
|
+
successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
|
|
766
|
+
params: {
|
|
767
|
+
comment: {
|
|
768
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
769
|
+
},
|
|
770
|
+
attachments: {
|
|
771
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
approval_reject: {
|
|
776
|
+
label: "\u5374\u4E0B",
|
|
777
|
+
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",
|
|
778
|
+
successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
|
|
779
|
+
params: {
|
|
780
|
+
comment: {
|
|
781
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
782
|
+
},
|
|
783
|
+
attachments: {
|
|
784
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
approval_reassign: {
|
|
789
|
+
label: "\u518D\u5272\u308A\u5F53\u3066",
|
|
790
|
+
successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
|
|
791
|
+
params: {
|
|
792
|
+
to: {
|
|
793
|
+
label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
|
|
794
|
+
helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
|
|
795
|
+
},
|
|
796
|
+
comment: {
|
|
797
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
approval_send_back: {
|
|
802
|
+
label: "\u5DEE\u3057\u623B\u3057",
|
|
803
|
+
successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
|
|
804
|
+
params: {
|
|
805
|
+
comment: {
|
|
806
|
+
label: "\u7406\u7531"
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
},
|
|
810
|
+
approval_request_info: {
|
|
811
|
+
label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
|
|
812
|
+
successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
|
|
813
|
+
params: {
|
|
814
|
+
comment: {
|
|
815
|
+
label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
approval_remind: {
|
|
820
|
+
label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
|
|
821
|
+
successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
|
|
822
|
+
params: {
|
|
823
|
+
comment: {
|
|
824
|
+
label: "\u30E1\u30E2"
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
},
|
|
828
|
+
approval_recall: {
|
|
829
|
+
label: "\u53D6\u308A\u4E0B\u3052",
|
|
830
|
+
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",
|
|
831
|
+
successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
|
|
832
|
+
params: {
|
|
833
|
+
comment: {
|
|
834
|
+
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
},
|
|
838
|
+
approval_resubmit: {
|
|
839
|
+
label: "\u518D\u63D0\u51FA",
|
|
840
|
+
successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
|
|
841
|
+
params: {
|
|
842
|
+
comment: {
|
|
843
|
+
label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
528
847
|
}
|
|
529
848
|
},
|
|
530
849
|
sys_approval_action: {
|
|
@@ -556,7 +875,14 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
556
875
|
approve: "\u627F\u8A8D",
|
|
557
876
|
reject: "\u5374\u4E0B",
|
|
558
877
|
recall: "\u53D6\u6D88",
|
|
559
|
-
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
|
|
878
|
+
escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
|
|
879
|
+
reassign: "\u518D\u5272\u308A\u5F53\u3066",
|
|
880
|
+
remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
|
|
881
|
+
request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
|
|
882
|
+
comment: "\u30B3\u30E1\u30F3\u30C8",
|
|
883
|
+
revise: "\u5DEE\u3057\u623B\u3057",
|
|
884
|
+
resubmit: "\u518D\u63D0\u51FA",
|
|
885
|
+
ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
|
|
560
886
|
}
|
|
561
887
|
},
|
|
562
888
|
actor_id: {
|
|
@@ -565,13 +891,29 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
565
891
|
comment: {
|
|
566
892
|
label: "\u30B3\u30E1\u30F3\u30C8"
|
|
567
893
|
},
|
|
894
|
+
reassign_from: {
|
|
895
|
+
label: "\u5F15\u304D\u7D99\u304E\u5143",
|
|
896
|
+
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"
|
|
897
|
+
},
|
|
898
|
+
reassign_to: {
|
|
899
|
+
label: "\u5F15\u304D\u7D99\u304E\u5148",
|
|
900
|
+
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"
|
|
901
|
+
},
|
|
902
|
+
attachments: {
|
|
903
|
+
label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
|
|
904
|
+
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"
|
|
905
|
+
},
|
|
568
906
|
created_at: {
|
|
569
907
|
label: "\u4F5C\u6210\u65E5\u6642"
|
|
570
908
|
}
|
|
571
909
|
},
|
|
572
910
|
_views: {
|
|
573
911
|
recent: {
|
|
574
|
-
label: "\u6700\u8FD1"
|
|
912
|
+
label: "\u6700\u8FD1",
|
|
913
|
+
emptyState: {
|
|
914
|
+
title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
|
|
915
|
+
message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
|
|
916
|
+
}
|
|
575
917
|
},
|
|
576
918
|
by_actor: {
|
|
577
919
|
label: "\u64CD\u4F5C\u8005\u5225"
|
|
@@ -675,7 +1017,8 @@ var init_es_ES_objects_generated = __esm({
|
|
|
675
1017
|
pending: "Pendiente",
|
|
676
1018
|
approved: "Aprobada",
|
|
677
1019
|
rejected: "Rechazada",
|
|
678
|
-
recalled: "Retirada"
|
|
1020
|
+
recalled: "Retirada",
|
|
1021
|
+
returned: "Devuelta para revisi\xF3n"
|
|
679
1022
|
}
|
|
680
1023
|
},
|
|
681
1024
|
current_step: {
|
|
@@ -717,7 +1060,11 @@ var init_es_ES_objects_generated = __esm({
|
|
|
717
1060
|
},
|
|
718
1061
|
_views: {
|
|
719
1062
|
my_pending: {
|
|
720
|
-
label: "Mis pendientes"
|
|
1063
|
+
label: "Mis pendientes",
|
|
1064
|
+
emptyState: {
|
|
1065
|
+
title: "Sin aprobaciones pendientes",
|
|
1066
|
+
message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
|
|
1067
|
+
}
|
|
721
1068
|
},
|
|
722
1069
|
submitted_by_me: {
|
|
723
1070
|
label: "Enviadas por m\xED"
|
|
@@ -728,6 +1075,92 @@ var init_es_ES_objects_generated = __esm({
|
|
|
728
1075
|
all_requests: {
|
|
729
1076
|
label: "Todas"
|
|
730
1077
|
}
|
|
1078
|
+
},
|
|
1079
|
+
_actions: {
|
|
1080
|
+
approval_approve: {
|
|
1081
|
+
label: "Aprobar",
|
|
1082
|
+
successMessage: "Aprobada.",
|
|
1083
|
+
params: {
|
|
1084
|
+
comment: {
|
|
1085
|
+
label: "Comentario"
|
|
1086
|
+
},
|
|
1087
|
+
attachments: {
|
|
1088
|
+
label: "Adjuntos"
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
},
|
|
1092
|
+
approval_reject: {
|
|
1093
|
+
label: "Rechazar",
|
|
1094
|
+
confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
|
|
1095
|
+
successMessage: "Rechazada.",
|
|
1096
|
+
params: {
|
|
1097
|
+
comment: {
|
|
1098
|
+
label: "Comentario"
|
|
1099
|
+
},
|
|
1100
|
+
attachments: {
|
|
1101
|
+
label: "Adjuntos"
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
},
|
|
1105
|
+
approval_reassign: {
|
|
1106
|
+
label: "Reasignar",
|
|
1107
|
+
successMessage: "Reasignada.",
|
|
1108
|
+
params: {
|
|
1109
|
+
to: {
|
|
1110
|
+
label: "Nuevo aprobador",
|
|
1111
|
+
helpText: "Usuario al que se entrega este paso"
|
|
1112
|
+
},
|
|
1113
|
+
comment: {
|
|
1114
|
+
label: "Comentario"
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
},
|
|
1118
|
+
approval_send_back: {
|
|
1119
|
+
label: "Devolver",
|
|
1120
|
+
successMessage: "Devuelta para revisi\xF3n.",
|
|
1121
|
+
params: {
|
|
1122
|
+
comment: {
|
|
1123
|
+
label: "Motivo"
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
},
|
|
1127
|
+
approval_request_info: {
|
|
1128
|
+
label: "Pedir informaci\xF3n",
|
|
1129
|
+
successMessage: "Informaci\xF3n solicitada.",
|
|
1130
|
+
params: {
|
|
1131
|
+
comment: {
|
|
1132
|
+
label: "\xBFQu\xE9 necesitas?"
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
},
|
|
1136
|
+
approval_remind: {
|
|
1137
|
+
label: "Enviar recordatorio",
|
|
1138
|
+
successMessage: "Recordatorio enviado.",
|
|
1139
|
+
params: {
|
|
1140
|
+
comment: {
|
|
1141
|
+
label: "Nota"
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
},
|
|
1145
|
+
approval_recall: {
|
|
1146
|
+
label: "Retirar",
|
|
1147
|
+
confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
|
|
1148
|
+
successMessage: "Retirada.",
|
|
1149
|
+
params: {
|
|
1150
|
+
comment: {
|
|
1151
|
+
label: "Comentario"
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
approval_resubmit: {
|
|
1156
|
+
label: "Reenviar",
|
|
1157
|
+
successMessage: "Reenviada.",
|
|
1158
|
+
params: {
|
|
1159
|
+
comment: {
|
|
1160
|
+
label: "\xBFQu\xE9 ha cambiado?"
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
731
1164
|
}
|
|
732
1165
|
},
|
|
733
1166
|
sys_approval_action: {
|
|
@@ -759,7 +1192,14 @@ var init_es_ES_objects_generated = __esm({
|
|
|
759
1192
|
approve: "Aprobar",
|
|
760
1193
|
reject: "Rechazar",
|
|
761
1194
|
recall: "Retirar",
|
|
762
|
-
escalate: "Escalar"
|
|
1195
|
+
escalate: "Escalar",
|
|
1196
|
+
reassign: "Reasignaci\xF3n",
|
|
1197
|
+
remind: "Recordatorio",
|
|
1198
|
+
request_info: "Petici\xF3n de informaci\xF3n",
|
|
1199
|
+
comment: "Comentario",
|
|
1200
|
+
revise: "Devoluci\xF3n",
|
|
1201
|
+
resubmit: "Reenv\xEDo",
|
|
1202
|
+
ooo_substitute: "Sustituci\xF3n por ausencia"
|
|
763
1203
|
}
|
|
764
1204
|
},
|
|
765
1205
|
actor_id: {
|
|
@@ -768,13 +1208,29 @@ var init_es_ES_objects_generated = __esm({
|
|
|
768
1208
|
comment: {
|
|
769
1209
|
label: "Comentario"
|
|
770
1210
|
},
|
|
1211
|
+
reassign_from: {
|
|
1212
|
+
label: "Reasignado de",
|
|
1213
|
+
help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
|
|
1214
|
+
},
|
|
1215
|
+
reassign_to: {
|
|
1216
|
+
label: "Reasignado a",
|
|
1217
|
+
help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
|
|
1218
|
+
},
|
|
1219
|
+
attachments: {
|
|
1220
|
+
label: "Adjuntos",
|
|
1221
|
+
help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
|
|
1222
|
+
},
|
|
771
1223
|
created_at: {
|
|
772
1224
|
label: "Creado el"
|
|
773
1225
|
}
|
|
774
1226
|
},
|
|
775
1227
|
_views: {
|
|
776
1228
|
recent: {
|
|
777
|
-
label: "Recientes"
|
|
1229
|
+
label: "Recientes",
|
|
1230
|
+
emptyState: {
|
|
1231
|
+
title: "A\xFAn no hay acciones de aprobaci\xF3n",
|
|
1232
|
+
message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
|
|
1233
|
+
}
|
|
778
1234
|
},
|
|
779
1235
|
by_actor: {
|
|
780
1236
|
label: "Por actor"
|
|
@@ -874,6 +1330,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
874
1330
|
|
|
875
1331
|
// src/sys-approval-request.object.ts
|
|
876
1332
|
var import_data = require("@objectstack/spec/data");
|
|
1333
|
+
var import_contracts = require("@objectstack/spec/contracts");
|
|
877
1334
|
var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
878
1335
|
name: "sys_approval_request",
|
|
879
1336
|
label: "Approval Request",
|
|
@@ -972,9 +1429,10 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
972
1429
|
group: "Target"
|
|
973
1430
|
}),
|
|
974
1431
|
status: import_data.Field.select(
|
|
975
|
-
//
|
|
976
|
-
//
|
|
977
|
-
|
|
1432
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
|
|
1433
|
+
// where the list and the reason for each entry live; `ApprovalStatus` is
|
|
1434
|
+
// derived from it, so this column and the contract cannot disagree.
|
|
1435
|
+
[...import_contracts.APPROVAL_STATUSES],
|
|
978
1436
|
{
|
|
979
1437
|
label: "Status",
|
|
980
1438
|
required: true,
|
|
@@ -1072,13 +1530,22 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1072
1530
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1073
1531
|
// (the caller is a current pending approver — same check the service
|
|
1074
1532
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1075
|
-
// submitter actions on `record.viewer.is_submitter`.
|
|
1076
|
-
//
|
|
1533
|
+
// submitter actions on `record.viewer.is_submitter`. The core decision levers
|
|
1534
|
+
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
|
|
1535
|
+
// (#3424) so a platform/tenant admin can rescue a request routed to an
|
|
1536
|
+
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
1537
|
+
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
1538
|
+
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
1539
|
+
// closed.
|
|
1077
1540
|
actions: [
|
|
1078
1541
|
{
|
|
1079
1542
|
name: "approval_approve",
|
|
1080
1543
|
label: "Approve",
|
|
1081
1544
|
icon: "check-circle",
|
|
1545
|
+
// Primary decision — the console renders this filled/highlighted so it
|
|
1546
|
+
// stands out from the secondary levers in the drawer's action bar,
|
|
1547
|
+
// matching the mobile card hierarchy (objectui#2762 P1-5).
|
|
1548
|
+
variant: "primary",
|
|
1082
1549
|
type: "api",
|
|
1083
1550
|
method: "POST",
|
|
1084
1551
|
target: "/api/v1/approvals/requests/{id}/approve",
|
|
@@ -1089,7 +1556,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1089
1556
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1090
1557
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1091
1558
|
],
|
|
1092
|
-
visible: "record.viewer.can_act",
|
|
1559
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1093
1560
|
locations: ["record_section", "list_item"],
|
|
1094
1561
|
successMessage: "Approved.",
|
|
1095
1562
|
refreshAfter: true
|
|
@@ -1098,6 +1565,9 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1098
1565
|
name: "approval_reject",
|
|
1099
1566
|
label: "Reject",
|
|
1100
1567
|
icon: "x-circle",
|
|
1568
|
+
// Destructive decision — rendered in the console's danger styling so it
|
|
1569
|
+
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
1570
|
+
variant: "danger",
|
|
1101
1571
|
type: "api",
|
|
1102
1572
|
method: "POST",
|
|
1103
1573
|
target: "/api/v1/approvals/requests/{id}/reject",
|
|
@@ -1105,7 +1575,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1105
1575
|
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1106
1576
|
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1107
1577
|
],
|
|
1108
|
-
visible: "record.viewer.can_act",
|
|
1578
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1109
1579
|
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1110
1580
|
locations: ["record_section", "list_item"],
|
|
1111
1581
|
successMessage: "Rejected.",
|
|
@@ -1127,7 +1597,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1127
1597
|
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1128
1598
|
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1129
1599
|
],
|
|
1130
|
-
visible: "record.viewer.can_act",
|
|
1600
|
+
visible: "record.viewer.can_act || record.viewer.can_override",
|
|
1131
1601
|
locations: ["record_section"],
|
|
1132
1602
|
successMessage: "Reassigned.",
|
|
1133
1603
|
refreshAfter: true
|
|
@@ -1231,6 +1701,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
1231
1701
|
|
|
1232
1702
|
// src/sys-approval-action.object.ts
|
|
1233
1703
|
var import_data2 = require("@objectstack/spec/data");
|
|
1704
|
+
var import_contracts2 = require("@objectstack/spec/contracts");
|
|
1234
1705
|
var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
1235
1706
|
name: "sys_approval_action",
|
|
1236
1707
|
label: "Approval Action",
|
|
@@ -1244,6 +1715,14 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1244
1715
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
1245
1716
|
titleFormat: "{action} \xB7 {step_name}",
|
|
1246
1717
|
highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
|
|
1718
|
+
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
1719
|
+
// are OWNED by this row — and the storage service would otherwise authorize
|
|
1720
|
+
// their download by testing whether the caller can READ this row. It cannot:
|
|
1721
|
+
// this table is deliberately closed to ordinary approver positions, so that
|
|
1722
|
+
// test denies the very approver the attachment was filed for. The approvals
|
|
1723
|
+
// service already owns the rule for seeing a decision (visibility of the
|
|
1724
|
+
// parent request, exactly as `listActions` applies it), so it answers.
|
|
1725
|
+
fileAccessDelegate: "approvals",
|
|
1247
1726
|
listViews: {
|
|
1248
1727
|
recent: {
|
|
1249
1728
|
type: "grid",
|
|
@@ -1301,12 +1780,11 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1301
1780
|
group: "Target"
|
|
1302
1781
|
}),
|
|
1303
1782
|
action: import_data2.Field.select(
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
1783
|
+
// Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
|
|
1784
|
+
// is where the list and the per-kind notes live (which kinds move the flow
|
|
1785
|
+
// and which are thread-only); `ApprovalActionKind` is derived from it, so
|
|
1786
|
+
// this column and the contract cannot disagree.
|
|
1787
|
+
[...import_contracts2.APPROVAL_ACTION_KINDS],
|
|
1310
1788
|
{
|
|
1311
1789
|
label: "Action",
|
|
1312
1790
|
required: true,
|
|
@@ -1319,6 +1797,23 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
1319
1797
|
group: "Action"
|
|
1320
1798
|
}),
|
|
1321
1799
|
comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1800
|
+
// Structured hand-off parties for `action: 'reassign'` (#4365). Before
|
|
1801
|
+
// these existed the pair lived only inside a default free-text comment
|
|
1802
|
+
// ("<from_id> → <to_id>"), which no client could parse or render readably.
|
|
1803
|
+
// `comment` is pure user input again; timelines render "from A to B" from
|
|
1804
|
+
// these fields.
|
|
1805
|
+
reassign_from: import_data2.Field.lookup("sys_user", {
|
|
1806
|
+
label: "Reassigned From",
|
|
1807
|
+
required: false,
|
|
1808
|
+
group: "Action",
|
|
1809
|
+
description: "User whose pending-approver slot was handed over (reassign actions only)"
|
|
1810
|
+
}),
|
|
1811
|
+
reassign_to: import_data2.Field.lookup("sys_user", {
|
|
1812
|
+
label: "Reassigned To",
|
|
1813
|
+
required: false,
|
|
1814
|
+
group: "Action",
|
|
1815
|
+
description: "User who received the pending-approver slot (reassign actions only)"
|
|
1816
|
+
}),
|
|
1322
1817
|
attachments: import_data2.Field.file({
|
|
1323
1818
|
label: "Attachments",
|
|
1324
1819
|
required: false,
|
|
@@ -1495,23 +1990,167 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
|
1495
1990
|
trackHistory: true,
|
|
1496
1991
|
searchable: true,
|
|
1497
1992
|
apiEnabled: true,
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1993
|
+
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
|
|
1994
|
+
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
|
|
1995
|
+
apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
|
|
1501
1996
|
}
|
|
1502
1997
|
});
|
|
1503
1998
|
|
|
1504
1999
|
// src/approval-service.ts
|
|
1505
2000
|
var import_node_crypto = require("crypto");
|
|
1506
2001
|
var import_automation = require("@objectstack/spec/automation");
|
|
2002
|
+
var import_formula = require("@objectstack/formula");
|
|
2003
|
+
var import_types = require("@objectstack/types");
|
|
2004
|
+
var import_identity = require("@objectstack/spec/identity");
|
|
2005
|
+
var import_contracts3 = require("@objectstack/spec/contracts");
|
|
2006
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
1507
2007
|
var import_core = require("@objectstack/core");
|
|
2008
|
+
|
|
2009
|
+
// src/approver-org-scope.ts
|
|
2010
|
+
var MAX_ORG_DEPTH = 32;
|
|
2011
|
+
var SYSTEM_CTX = { isSystem: true };
|
|
2012
|
+
function fail(message) {
|
|
2013
|
+
throw new Error(`VALIDATION_FAILED: ${message}`);
|
|
2014
|
+
}
|
|
2015
|
+
async function findOrg(engine, where) {
|
|
2016
|
+
try {
|
|
2017
|
+
const rows = await engine.find("sys_organization", {
|
|
2018
|
+
where,
|
|
2019
|
+
fields: ["id", "slug", "parent_organization_id"],
|
|
2020
|
+
limit: 1,
|
|
2021
|
+
context: SYSTEM_CTX
|
|
2022
|
+
});
|
|
2023
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
2024
|
+
} catch {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
async function ancestorChain(engine, orgId) {
|
|
2029
|
+
const chain = [];
|
|
2030
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2031
|
+
let cursor = orgId;
|
|
2032
|
+
for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
|
|
2033
|
+
if (seen.has(cursor)) break;
|
|
2034
|
+
seen.add(cursor);
|
|
2035
|
+
chain.push(cursor);
|
|
2036
|
+
const row = await findOrg(engine, { id: cursor });
|
|
2037
|
+
const parent = row?.parent_organization_id;
|
|
2038
|
+
cursor = parent ? String(parent) : null;
|
|
2039
|
+
}
|
|
2040
|
+
return chain;
|
|
2041
|
+
}
|
|
2042
|
+
async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
|
|
2043
|
+
const declared = typeof declaration === "string" ? declaration.trim() : "";
|
|
2044
|
+
if (!declared) return requestOrgId;
|
|
2045
|
+
if (!isOrgScopedType) {
|
|
2046
|
+
fail(
|
|
2047
|
+
`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)`
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
const posture = deps.posture?.();
|
|
2051
|
+
if (posture && posture !== "group") {
|
|
2052
|
+
fail(
|
|
2053
|
+
`cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2057
|
+
if (!requestOrg) {
|
|
2058
|
+
fail(
|
|
2059
|
+
`'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
|
|
2060
|
+
);
|
|
2061
|
+
}
|
|
2062
|
+
const chain = await ancestorChain(deps.engine, requestOrg);
|
|
2063
|
+
if (declared === "$root") {
|
|
2064
|
+
const root = chain[chain.length - 1];
|
|
2065
|
+
if (root === requestOrg && chain.length === 1) {
|
|
2066
|
+
fail(
|
|
2067
|
+
`'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`
|
|
2068
|
+
);
|
|
2069
|
+
}
|
|
2070
|
+
return root;
|
|
2071
|
+
}
|
|
2072
|
+
if (declared === "$parent") {
|
|
2073
|
+
const parent = chain[1];
|
|
2074
|
+
if (!parent) {
|
|
2075
|
+
fail(
|
|
2076
|
+
`'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')`
|
|
2077
|
+
);
|
|
2078
|
+
}
|
|
2079
|
+
return parent;
|
|
2080
|
+
}
|
|
2081
|
+
const target = await findOrg(deps.engine, { slug: declared });
|
|
2082
|
+
if (!target?.id) {
|
|
2083
|
+
fail(
|
|
2084
|
+
`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)`
|
|
2085
|
+
);
|
|
2086
|
+
}
|
|
2087
|
+
const targetId = String(target.id);
|
|
2088
|
+
if (targetId === requestOrg) return targetId;
|
|
2089
|
+
const targetChain = await ancestorChain(deps.engine, targetId);
|
|
2090
|
+
const targetRoot = targetChain[targetChain.length - 1];
|
|
2091
|
+
const requestRoot = chain[chain.length - 1];
|
|
2092
|
+
if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
|
|
2093
|
+
fail(
|
|
2094
|
+
`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)`
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
return targetId;
|
|
2098
|
+
}
|
|
2099
|
+
async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
|
|
2100
|
+
const requestOrg = requestOrgId ? String(requestOrgId) : "";
|
|
2101
|
+
if (!requestOrg || userIds.length === 0) return userIds;
|
|
2102
|
+
let members = [];
|
|
2103
|
+
try {
|
|
2104
|
+
members = await deps.engine.find("sys_member", {
|
|
2105
|
+
where: { organization_id: requestOrg, user_id: { $in: userIds } },
|
|
2106
|
+
fields: ["user_id"],
|
|
2107
|
+
limit: 1e4,
|
|
2108
|
+
context: SYSTEM_CTX
|
|
2109
|
+
});
|
|
2110
|
+
} catch {
|
|
2111
|
+
return userIds;
|
|
2112
|
+
}
|
|
2113
|
+
const canRead = new Set(
|
|
2114
|
+
(members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
|
|
2115
|
+
);
|
|
2116
|
+
const dropped = userIds.filter((u) => !canRead.has(u));
|
|
2117
|
+
if (dropped.length === 0) return userIds;
|
|
2118
|
+
deps.logger?.warn?.(
|
|
2119
|
+
`[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.`,
|
|
2120
|
+
{ dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
|
|
2121
|
+
);
|
|
2122
|
+
return userIds.filter((u) => canRead.has(u));
|
|
2123
|
+
}
|
|
2124
|
+
|
|
2125
|
+
// src/approval-service.ts
|
|
1508
2126
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1509
2127
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1510
2128
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1511
2129
|
var SLA_ACTOR_ID = "system:sla";
|
|
2130
|
+
var DEAD_RUN_ACTOR_ID = "system:dead-run";
|
|
2131
|
+
var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
|
|
2132
|
+
"completed",
|
|
2133
|
+
"failed",
|
|
2134
|
+
"cancelled",
|
|
2135
|
+
"timed_out"
|
|
2136
|
+
]);
|
|
1512
2137
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1513
|
-
var
|
|
2138
|
+
var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
|
|
2139
|
+
function actingUserId(context) {
|
|
2140
|
+
const userId = context?.userId;
|
|
2141
|
+
return typeof userId === "string" && userId ? userId : null;
|
|
2142
|
+
}
|
|
1514
2143
|
var OOO_MAX_CHAIN = 8;
|
|
2144
|
+
var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
|
|
2145
|
+
"team",
|
|
2146
|
+
"department",
|
|
2147
|
+
"business_unit",
|
|
2148
|
+
"bu",
|
|
2149
|
+
"position",
|
|
2150
|
+
"org_membership_level",
|
|
2151
|
+
"manager"
|
|
2152
|
+
]);
|
|
2153
|
+
var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
|
|
1515
2154
|
function uid(prefix) {
|
|
1516
2155
|
const g = globalThis;
|
|
1517
2156
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1533,6 +2172,12 @@ function csvSplit(raw) {
|
|
|
1533
2172
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
1534
2173
|
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
1535
2174
|
}
|
|
2175
|
+
function isBlankDecisionOutput(value) {
|
|
2176
|
+
if (value === void 0 || value === null) return true;
|
|
2177
|
+
if (typeof value === "string") return value.trim() === "";
|
|
2178
|
+
if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
|
|
2179
|
+
return false;
|
|
2180
|
+
}
|
|
1536
2181
|
function prettifyMachineName(raw) {
|
|
1537
2182
|
if (!raw) return void 0;
|
|
1538
2183
|
const base = String(raw).replace(/^flow:/, "").trim();
|
|
@@ -1565,7 +2210,26 @@ function rowFromRequest(row) {
|
|
|
1565
2210
|
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
|
|
1566
2211
|
sla_due_at: slaDueAt(row.created_at, cfg),
|
|
1567
2212
|
// ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
|
|
1568
|
-
round: typeof cfg?.__round === "number" ? cfg.__round : void 0
|
|
2213
|
+
round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
|
|
2214
|
+
// objectui#2902: the node's record-lock policy. The lock is enforced
|
|
2215
|
+
// server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
|
|
2216
|
+
// same `!== false` default, so the flag a client renders and the rule the
|
|
2217
|
+
// server applies can never drift. Without it a console can only see
|
|
2218
|
+
// "a pending request exists" and has to assume the record is locked —
|
|
2219
|
+
// which mislabels every `lockRecord: false` node as locked and hides an
|
|
2220
|
+
// edit the server would have accepted.
|
|
2221
|
+
lock_record: cfg?.lockRecord !== false,
|
|
2222
|
+
// #3447 P2: the node's author-declared decision outputs, surfaced so a
|
|
2223
|
+
// decision UI can render input fields for them and POST `outputs` on
|
|
2224
|
+
// approve/reject. Per-request (each node declares its own), which is why
|
|
2225
|
+
// this rides the row instead of the static action params. Two shapes for
|
|
2226
|
+
// version skew: `decision_outputs` stays the bare KEY list an older
|
|
2227
|
+
// console renders as text inputs; `decision_output_defs` carries the
|
|
2228
|
+
// normalized typed declarations a picker-aware console prefers.
|
|
2229
|
+
...(() => {
|
|
2230
|
+
const defs = (0, import_automation.normalizeDecisionOutputs)(cfg?.decisionOutputs);
|
|
2231
|
+
return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
|
|
2232
|
+
})()
|
|
1569
2233
|
};
|
|
1570
2234
|
}
|
|
1571
2235
|
function slaDueAt(createdAt, cfg) {
|
|
@@ -1575,7 +2239,29 @@ function slaDueAt(createdAt, cfg) {
|
|
|
1575
2239
|
if (Number.isNaN(t)) return void 0;
|
|
1576
2240
|
return new Date(t + hours * 36e5).toISOString();
|
|
1577
2241
|
}
|
|
2242
|
+
function normalizeActionAttachment(entry) {
|
|
2243
|
+
if (entry == null) return void 0;
|
|
2244
|
+
if (typeof entry === "string") {
|
|
2245
|
+
const id = entry.trim();
|
|
2246
|
+
if (!id) return void 0;
|
|
2247
|
+
return (0, import_data5.isFileIdToken)(id) ? { id } : { id, url: id };
|
|
2248
|
+
}
|
|
2249
|
+
if (typeof entry === "object") {
|
|
2250
|
+
const id = entry.id ?? entry.file_id;
|
|
2251
|
+
if (id == null || String(id) === "") return void 0;
|
|
2252
|
+
const mimeType = entry.mimeType ?? entry.mime_type;
|
|
2253
|
+
return {
|
|
2254
|
+
id: String(id),
|
|
2255
|
+
name: typeof entry.name === "string" ? entry.name : void 0,
|
|
2256
|
+
url: typeof entry.url === "string" ? entry.url : void 0,
|
|
2257
|
+
mimeType: typeof mimeType === "string" ? mimeType : void 0,
|
|
2258
|
+
size: typeof entry.size === "number" ? entry.size : void 0
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
return void 0;
|
|
2262
|
+
}
|
|
1578
2263
|
function rowFromAction(row) {
|
|
2264
|
+
const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
|
|
1579
2265
|
return {
|
|
1580
2266
|
id: String(row.id),
|
|
1581
2267
|
request_id: String(row.request_id),
|
|
@@ -1584,10 +2270,12 @@ function rowFromAction(row) {
|
|
|
1584
2270
|
action: row.action,
|
|
1585
2271
|
actor_id: row.actor_id ?? void 0,
|
|
1586
2272
|
comment: row.comment ?? void 0,
|
|
1587
|
-
//
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
attachments
|
|
2273
|
+
// Structured reassign hand-off parties (#4365).
|
|
2274
|
+
reassign_from: row.reassign_from ?? void 0,
|
|
2275
|
+
reassign_to: row.reassign_to ?? void 0,
|
|
2276
|
+
// Decision attachments (#3266): rich descriptors carrying the display name +
|
|
2277
|
+
// download URL, so consumers label/open them without reading `sys_file`.
|
|
2278
|
+
attachments: attachments.length ? attachments : void 0,
|
|
1591
2279
|
created_at: row.created_at ?? void 0
|
|
1592
2280
|
};
|
|
1593
2281
|
}
|
|
@@ -1599,6 +2287,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1599
2287
|
this.automation = opts.automation;
|
|
1600
2288
|
this.messaging = opts.messaging;
|
|
1601
2289
|
this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
|
|
2290
|
+
this.tenancyPosture = opts.tenancyPosture;
|
|
2291
|
+
}
|
|
2292
|
+
/** Attach (or replace) the ADR-0105 D9 posture provider. */
|
|
2293
|
+
attachTenancyPosture(provider) {
|
|
2294
|
+
this.tenancyPosture = provider;
|
|
2295
|
+
}
|
|
2296
|
+
/** Deps bundle for the ADR-0105 D9 org-scope helpers. */
|
|
2297
|
+
get orgScopeDeps() {
|
|
2298
|
+
return {
|
|
2299
|
+
engine: this.engine,
|
|
2300
|
+
posture: this.tenancyPosture,
|
|
2301
|
+
logger: this.logger
|
|
2302
|
+
};
|
|
2303
|
+
}
|
|
2304
|
+
/**
|
|
2305
|
+
* [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
|
|
2306
|
+
* Absent declaration ⇒ the request's own organization (unchanged, no reads).
|
|
2307
|
+
*/
|
|
2308
|
+
async directoryOrgFor(a, requestOrgId) {
|
|
2309
|
+
const rawType = String(a?.type ?? "");
|
|
2310
|
+
return resolveApproverDirectoryOrg(
|
|
2311
|
+
this.orgScopeDeps,
|
|
2312
|
+
a?.organization,
|
|
2313
|
+
requestOrgId,
|
|
2314
|
+
rawType,
|
|
2315
|
+
(0, import_automation.approverTypeIsOrgScoped)(rawType)
|
|
2316
|
+
);
|
|
1602
2317
|
}
|
|
1603
2318
|
/** Attach (or replace) the automation surface used to resume flow runs. */
|
|
1604
2319
|
attachAutomation(automation) {
|
|
@@ -1633,13 +2348,108 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1633
2348
|
const rows = await this.engine.find("sys_approval_request", {
|
|
1634
2349
|
where: { id: requestId },
|
|
1635
2350
|
limit: 1,
|
|
1636
|
-
context:
|
|
2351
|
+
context: SYSTEM_CTX2
|
|
1637
2352
|
});
|
|
1638
2353
|
const raw = Array.isArray(rows) ? rows[0] : null;
|
|
1639
2354
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
1640
2355
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
1641
2356
|
return raw;
|
|
1642
2357
|
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Privileged-override gate (#3424). A stuck approval — one routed to a
|
|
2360
|
+
* position/team with no holders (so its `pending_approvers` is only an
|
|
2361
|
+
* unresolvable `type:value` literal) or to approvers who have all since left —
|
|
2362
|
+
* is otherwise undecidable: no concrete user is in the slate, so every normal
|
|
2363
|
+
* `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
|
|
2364
|
+
* record stays locked forever with no in-product recovery. A platform or
|
|
2365
|
+
* tenant admin — the same posture the engine's superuser bypass already
|
|
2366
|
+
* trusts — may always act on a PENDING request to release it: approve, reject,
|
|
2367
|
+
* reassign it to a real approver, or recall it.
|
|
2368
|
+
*
|
|
2369
|
+
* A platform admin crosses the tenant wall (matching the unscoped
|
|
2370
|
+
* `admin_full_access` evidence); a tenant admin may override only within their
|
|
2371
|
+
* own org (or an org-less request). A system context always passes. Signals are
|
|
2372
|
+
* read defensively off the resolved exec context (`permissions` / `positions` /
|
|
2373
|
+
* the derived `posture`, ADR-0095) so any transport that resolves through the
|
|
2374
|
+
* shared authz resolver lights this up without extra wiring.
|
|
2375
|
+
*/
|
|
2376
|
+
isOverrideActor(context, requestOrg) {
|
|
2377
|
+
if (!context) return false;
|
|
2378
|
+
if (context.isSystem) return true;
|
|
2379
|
+
const perms = Array.isArray(context.permissions) ? context.permissions : [];
|
|
2380
|
+
const positions = Array.isArray(context.positions) ? context.positions : [];
|
|
2381
|
+
const posture = context.posture;
|
|
2382
|
+
const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS) || positions.includes(import_identity.BUILTIN_IDENTITY_PLATFORM_ADMIN);
|
|
2383
|
+
if (isPlatformAdmin) return true;
|
|
2384
|
+
const isTenantAdmin = posture === "TENANT_ADMIN" || import_identity.ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_ADMIN);
|
|
2385
|
+
if (!isTenantAdmin) return false;
|
|
2386
|
+
const actorTenant = context.tenantId ?? context.organizationId ?? null;
|
|
2387
|
+
return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Pin the acting identity to the AUTHENTICATED CALLER (#3800).
|
|
2391
|
+
*
|
|
2392
|
+
* Every public entrypoint accepts an `actorId`, and the REST routes fill it
|
|
2393
|
+
* from `body.actorId ?? body.actor_id ?? context.userId` — so before this
|
|
2394
|
+
* gate the body won. The authorization checks downstream all read that value
|
|
2395
|
+
* (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
|
|
2396
|
+
* which made the body-supplied string not merely the audit label but the key
|
|
2397
|
+
* that opens the door: any authenticated user could name a pending approver
|
|
2398
|
+
* and have that approver's decision recorded and the owning flow resumed.
|
|
2399
|
+
* #3783 drew this line for the data-write identity ({@link actingUserId});
|
|
2400
|
+
* this closes the authorization half.
|
|
2401
|
+
*
|
|
2402
|
+
* A caller may still name an identity OTHER than their bare user id, because
|
|
2403
|
+
* a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
|
|
2404
|
+
* `type:value` literal when a graph lookup yields nothing, and an author may
|
|
2405
|
+
* write an email as a `user` approver. So the rule is not "actorId must equal
|
|
2406
|
+
* userId" — it is **"actorId must be an identity the SERVER can prove belongs
|
|
2407
|
+
* to the caller"**. Anything else is `FORBIDDEN`.
|
|
2408
|
+
*
|
|
2409
|
+
* A system context is exempt and keeps its explicit actor: the SLA sweep
|
|
2410
|
+
* passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
|
|
2411
|
+
* link passes the approver its single-use token is cryptographically bound to
|
|
2412
|
+
* (having also put them on the context). Those are the only two callers that
|
|
2413
|
+
* hold a trustworthy actor with no session behind them.
|
|
2414
|
+
*
|
|
2415
|
+
* A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
|
|
2416
|
+
* anonymous-deny now denies every anonymous request (#3963), but this service
|
|
2417
|
+
* must not rely on a caller upstream — an anonymous actor could otherwise
|
|
2418
|
+
* decide approvals outright by naming one.
|
|
2419
|
+
*/
|
|
2420
|
+
async resolveActor(actorId, context) {
|
|
2421
|
+
if (context?.isSystem) {
|
|
2422
|
+
if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
2423
|
+
return actorId;
|
|
2424
|
+
}
|
|
2425
|
+
const uid2 = actingUserId(context);
|
|
2426
|
+
if (!uid2) {
|
|
2427
|
+
throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
|
|
2428
|
+
}
|
|
2429
|
+
if (!actorId || String(actorId) === uid2) return uid2;
|
|
2430
|
+
const named = String(actorId);
|
|
2431
|
+
for (const position of context.positions ?? []) {
|
|
2432
|
+
if (named === `position:${position}` || named === `role:${position}`) return named;
|
|
2433
|
+
}
|
|
2434
|
+
if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
|
|
2435
|
+
throw new Error(
|
|
2436
|
+
`FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
|
|
2437
|
+
);
|
|
2438
|
+
}
|
|
2439
|
+
/** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
|
|
2440
|
+
async callerHasEmail(userId, email) {
|
|
2441
|
+
try {
|
|
2442
|
+
const rows = await this.engine.find("sys_user", {
|
|
2443
|
+
where: { id: userId },
|
|
2444
|
+
limit: 1,
|
|
2445
|
+
context: SYSTEM_CTX2
|
|
2446
|
+
});
|
|
2447
|
+
const row = Array.isArray(rows) ? rows[0] : null;
|
|
2448
|
+
return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
|
|
2449
|
+
} catch {
|
|
2450
|
+
return false;
|
|
2451
|
+
}
|
|
2452
|
+
}
|
|
1643
2453
|
/**
|
|
1644
2454
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1645
2455
|
* graph tables for `team:` / `department:` / `position:` /
|
|
@@ -1674,7 +2484,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1674
2484
|
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1675
2485
|
*/
|
|
1676
2486
|
async expandApprovers(step, record, organizationId, opts) {
|
|
1677
|
-
var _a;
|
|
2487
|
+
var _a, _b, _c;
|
|
1678
2488
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1679
2489
|
const now = opts?.now ?? this.clock.now().getTime();
|
|
1680
2490
|
const out = [];
|
|
@@ -1682,12 +2492,34 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1682
2492
|
for (let idx = 0; idx < specs.length; idx++) {
|
|
1683
2493
|
const a = specs[idx];
|
|
1684
2494
|
if (!a) continue;
|
|
1685
|
-
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1686
2495
|
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
2496
|
+
if ((0, import_automation.canonicalApproverType)(String(a.type)) === "expression") {
|
|
2497
|
+
const resolved = await this.resolveExpressionApprovers(
|
|
2498
|
+
a,
|
|
2499
|
+
record,
|
|
2500
|
+
organizationId,
|
|
2501
|
+
now,
|
|
2502
|
+
opts?.substitutions,
|
|
2503
|
+
opts?.exprCtx
|
|
2504
|
+
);
|
|
2505
|
+
if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
|
|
2506
|
+
for (const entry of resolved.slots) {
|
|
2507
|
+
if (!entry.id) continue;
|
|
2508
|
+
out.push(entry.id);
|
|
2509
|
+
if (opts?.groups) {
|
|
2510
|
+
((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
if (opts?.resolvedFrom && (0, import_automation.canonicalApproverType)(String(a.type)) === "field" && a.value != null) {
|
|
2516
|
+
opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
|
|
2517
|
+
}
|
|
2518
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1687
2519
|
for (const u of ids) {
|
|
1688
2520
|
if (!u) continue;
|
|
1689
2521
|
out.push(u);
|
|
1690
|
-
if (opts?.groups) ((
|
|
2522
|
+
if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
|
|
1691
2523
|
}
|
|
1692
2524
|
}
|
|
1693
2525
|
return out.filter(Boolean);
|
|
@@ -1708,24 +2540,35 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1708
2540
|
{ deprecated: a.type, canonical: type }
|
|
1709
2541
|
);
|
|
1710
2542
|
}
|
|
2543
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2544
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
1711
2545
|
if (type === "user") {
|
|
1712
2546
|
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1713
2547
|
}
|
|
1714
2548
|
if (type === "field" && record) {
|
|
1715
|
-
|
|
2549
|
+
const out = [];
|
|
2550
|
+
for (const id of csvSplit(record[a.value])) {
|
|
2551
|
+
out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
|
|
2552
|
+
}
|
|
2553
|
+
return out;
|
|
1716
2554
|
}
|
|
2555
|
+
const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2556
|
+
approverType: type,
|
|
2557
|
+
value: a.value != null ? String(a.value) : void 0,
|
|
2558
|
+
directoryOrgId: directoryOrg
|
|
2559
|
+
}) : users;
|
|
1717
2560
|
try {
|
|
1718
2561
|
if (type === "team") {
|
|
1719
2562
|
const users = await this.expandTeamUsers(String(a.value));
|
|
1720
2563
|
if (users.length) return users;
|
|
1721
2564
|
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1722
|
-
const users = await this.expandBusinessUnitUsers(String(a.value),
|
|
2565
|
+
const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
|
|
1723
2566
|
if (users.length) return users;
|
|
1724
2567
|
} else if (type === "position") {
|
|
1725
|
-
const users = await this.expandPositionUsers(String(a.value),
|
|
2568
|
+
const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
|
|
1726
2569
|
if (users.length) return users;
|
|
1727
2570
|
} else if (type === "org_membership_level") {
|
|
1728
|
-
const users = await this.expandMembershipTierUsers(String(a.value),
|
|
2571
|
+
const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
|
|
1729
2572
|
if (users.length) return users;
|
|
1730
2573
|
} else if (type === "manager" && record) {
|
|
1731
2574
|
const subject = record[a.value] ?? record.owner_id;
|
|
@@ -1736,33 +2579,171 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1736
2579
|
}
|
|
1737
2580
|
} catch {
|
|
1738
2581
|
}
|
|
2582
|
+
if (type === "queue") {
|
|
2583
|
+
this.logger?.warn?.(
|
|
2584
|
+
`[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
|
|
2585
|
+
{ value: a.value }
|
|
2586
|
+
);
|
|
2587
|
+
} else if (GRAPH_APPROVER_TYPES.has(type)) {
|
|
2588
|
+
this.logger?.warn?.(
|
|
2589
|
+
`[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)`,
|
|
2590
|
+
{ type, value: a.value, organizationId: organizationId ?? null }
|
|
2591
|
+
);
|
|
2592
|
+
}
|
|
1739
2593
|
return [`${a.type}:${a.value}`];
|
|
1740
2594
|
}
|
|
2595
|
+
/**
|
|
2596
|
+
* Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
|
|
2597
|
+
* node entry against the three explicit roots — `current` (live record),
|
|
2598
|
+
* `trigger` (submit snapshot), `vars` (flow variables) — then expand the
|
|
2599
|
+
* result into people per `resolveAs`.
|
|
2600
|
+
*
|
|
2601
|
+
* Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
|
|
2602
|
+
* evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
|
|
2603
|
+
* loudly instead of opening a request routed to nobody — an approver
|
|
2604
|
+
* expression that cannot run is a routing bug, never "condition not met".
|
|
2605
|
+
* Error messages carry the correct spelling because their primary reader is
|
|
2606
|
+
* the AI author fixing the flow on the next validate pass.
|
|
2607
|
+
*
|
|
2608
|
+
* Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
|
|
2609
|
+
* expression's own values, pre-expansion) for the `__resolvedFrom` audit.
|
|
2610
|
+
*/
|
|
2611
|
+
async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
|
|
2612
|
+
const source = String(a.value ?? "").trim();
|
|
2613
|
+
if (!source) {
|
|
2614
|
+
throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
|
|
2615
|
+
}
|
|
2616
|
+
const parsed = (0, import_formula.collectCelRootIdentifiers)(source);
|
|
2617
|
+
if (!parsed.ok) {
|
|
2618
|
+
throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
|
|
2619
|
+
}
|
|
2620
|
+
const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
|
|
2621
|
+
if (illegal.length) {
|
|
2622
|
+
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)?`;
|
|
2623
|
+
throw new Error(
|
|
2624
|
+
`VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
const result = import_formula.ExpressionEngine.evaluate(
|
|
2628
|
+
{ dialect: "cel", source },
|
|
2629
|
+
{ extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
|
|
2630
|
+
);
|
|
2631
|
+
if (!result.ok) {
|
|
2632
|
+
throw new Error(
|
|
2633
|
+
`EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
|
|
2634
|
+
);
|
|
2635
|
+
}
|
|
2636
|
+
const value = result.value;
|
|
2637
|
+
let raw;
|
|
2638
|
+
if (value == null || value === "") {
|
|
2639
|
+
raw = [];
|
|
2640
|
+
} else if (typeof value === "string") {
|
|
2641
|
+
raw = csvSplit(value);
|
|
2642
|
+
} else if (Array.isArray(value)) {
|
|
2643
|
+
const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
|
|
2644
|
+
if (bad !== void 0) {
|
|
2645
|
+
throw new Error(
|
|
2646
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
|
|
2647
|
+
);
|
|
2648
|
+
}
|
|
2649
|
+
raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
|
|
2650
|
+
} else {
|
|
2651
|
+
throw new Error(
|
|
2652
|
+
`EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
|
|
2653
|
+
);
|
|
2654
|
+
}
|
|
2655
|
+
const resolveAs = String(a.resolveAs ?? "user");
|
|
2656
|
+
if (resolveAs === "user") {
|
|
2657
|
+
const slots2 = [];
|
|
2658
|
+
for (const id of raw) {
|
|
2659
|
+
for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
|
|
2660
|
+
slots2.push({ id: routed });
|
|
2661
|
+
}
|
|
2662
|
+
}
|
|
2663
|
+
return { slots: slots2, raw };
|
|
2664
|
+
}
|
|
2665
|
+
const directoryOrg = await this.directoryOrgFor(a, organizationId);
|
|
2666
|
+
const crossOrg = directoryOrg !== organizationId;
|
|
2667
|
+
const slots = [];
|
|
2668
|
+
for (const key of raw) {
|
|
2669
|
+
let users = [];
|
|
2670
|
+
try {
|
|
2671
|
+
if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
|
|
2672
|
+
else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
|
|
2673
|
+
else if (resolveAs === "team") users = await this.expandTeamUsers(key);
|
|
2674
|
+
else {
|
|
2675
|
+
throw new Error(
|
|
2676
|
+
`VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
|
|
2677
|
+
);
|
|
2678
|
+
}
|
|
2679
|
+
} catch (err) {
|
|
2680
|
+
if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
|
|
2681
|
+
users = [];
|
|
2682
|
+
}
|
|
2683
|
+
if (crossOrg && users.length) {
|
|
2684
|
+
users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
|
|
2685
|
+
approverType: resolveAs,
|
|
2686
|
+
value: key,
|
|
2687
|
+
directoryOrgId: directoryOrg
|
|
2688
|
+
});
|
|
2689
|
+
}
|
|
2690
|
+
if (!users.length) {
|
|
2691
|
+
slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
|
|
2692
|
+
continue;
|
|
2693
|
+
}
|
|
2694
|
+
for (const u of users) slots.push({ id: u, subGroup: key });
|
|
2695
|
+
}
|
|
2696
|
+
return { slots, raw };
|
|
2697
|
+
}
|
|
1741
2698
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1742
2699
|
async expandTeamUsers(teamId) {
|
|
1743
2700
|
if (!teamId) return [];
|
|
1744
2701
|
let rows = [];
|
|
1745
2702
|
try {
|
|
1746
2703
|
rows = await this.engine.find("sys_team_member", {
|
|
1747
|
-
|
|
2704
|
+
where: { team_id: teamId },
|
|
1748
2705
|
fields: ["user_id"],
|
|
1749
2706
|
limit: 1e4,
|
|
1750
|
-
context:
|
|
2707
|
+
context: SYSTEM_CTX2
|
|
1751
2708
|
});
|
|
1752
2709
|
} catch {
|
|
1753
2710
|
rows = [];
|
|
1754
2711
|
}
|
|
1755
2712
|
return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
|
|
1756
2713
|
}
|
|
2714
|
+
/**
|
|
2715
|
+
* Tenant scope for a `sys_business_unit` read that may legitimately be
|
|
2716
|
+
* env-wide (#3807).
|
|
2717
|
+
*
|
|
2718
|
+
* `organization_id = null` on a platform object means "owned by no
|
|
2719
|
+
* organization" — a row written by a seed, the file layer, or bootstrap,
|
|
2720
|
+
* i.e. before (or outside) any org exists. A strict
|
|
2721
|
+
* `organization_id = <request org>` equality made every such row invisible:
|
|
2722
|
+
* the seed check below found nothing, the whole expansion returned `[]`, and
|
|
2723
|
+
* the approver fell back to the dead `department:<id>` literal that routes to
|
|
2724
|
+
* nobody. That is not an edge case — an app's org tree is normally seeded
|
|
2725
|
+
* (a seed cannot know the org id the runtime mints at boot) while the
|
|
2726
|
+
* approval request always carries one, so EVERY department approver a
|
|
2727
|
+
* designer could pick resolved to nobody.
|
|
2728
|
+
*
|
|
2729
|
+
* Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
|
|
2730
|
+
* pending-draft listing settled on for the identical reason. Another org's
|
|
2731
|
+
* unit still fails the match, so the wall between two organizations is
|
|
2732
|
+
* unchanged — only rows belonging to no org at all become visible.
|
|
2733
|
+
*/
|
|
2734
|
+
businessUnitOrgScope(filter, organizationId) {
|
|
2735
|
+
if (!organizationId) return filter;
|
|
2736
|
+
return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
|
|
2737
|
+
}
|
|
1757
2738
|
/** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
|
|
1758
2739
|
async expandBusinessUnitUsers(businessUnitId, organizationId) {
|
|
1759
2740
|
if (!businessUnitId) return [];
|
|
1760
2741
|
try {
|
|
1761
2742
|
const seed = await this.engine.find("sys_business_unit", {
|
|
1762
|
-
|
|
2743
|
+
where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
|
|
1763
2744
|
fields: ["id", "active"],
|
|
1764
2745
|
limit: 1,
|
|
1765
|
-
context:
|
|
2746
|
+
context: SYSTEM_CTX2
|
|
1766
2747
|
});
|
|
1767
2748
|
const seedRow = Array.isArray(seed) ? seed[0] : null;
|
|
1768
2749
|
if (!seedRow || seedRow.active === false) return [];
|
|
@@ -1775,9 +2756,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1775
2756
|
const parent = queue.shift();
|
|
1776
2757
|
let kids = [];
|
|
1777
2758
|
try {
|
|
1778
|
-
const filter =
|
|
1779
|
-
|
|
1780
|
-
|
|
2759
|
+
const filter = this.businessUnitOrgScope(
|
|
2760
|
+
{ parent_business_unit_id: parent, active: { $ne: false } },
|
|
2761
|
+
organizationId
|
|
2762
|
+
);
|
|
2763
|
+
kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
|
|
1781
2764
|
} catch {
|
|
1782
2765
|
kids = [];
|
|
1783
2766
|
}
|
|
@@ -1792,10 +2775,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1792
2775
|
let rows = [];
|
|
1793
2776
|
try {
|
|
1794
2777
|
rows = await this.engine.find("sys_business_unit_member", {
|
|
1795
|
-
|
|
2778
|
+
where: { business_unit_id: { $in: Array.from(seen) } },
|
|
1796
2779
|
fields: ["user_id"],
|
|
1797
2780
|
limit: 1e4,
|
|
1798
|
-
context:
|
|
2781
|
+
context: SYSTEM_CTX2
|
|
1799
2782
|
});
|
|
1800
2783
|
} catch {
|
|
1801
2784
|
rows = [];
|
|
@@ -1820,7 +2803,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1820
2803
|
filter,
|
|
1821
2804
|
fields: ["user_id"],
|
|
1822
2805
|
limit: 1e4,
|
|
1823
|
-
context:
|
|
2806
|
+
context: SYSTEM_CTX2
|
|
1824
2807
|
});
|
|
1825
2808
|
for (const r of rows ?? []) {
|
|
1826
2809
|
const uid2 = String(r.user_id ?? "");
|
|
@@ -1843,7 +2826,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1843
2826
|
if (organizationId) filter.organization_id = organizationId;
|
|
1844
2827
|
let rows = [];
|
|
1845
2828
|
try {
|
|
1846
|
-
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context:
|
|
2829
|
+
rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
|
|
1847
2830
|
} catch {
|
|
1848
2831
|
rows = [];
|
|
1849
2832
|
}
|
|
@@ -1852,10 +2835,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1852
2835
|
async lookupManager(userId) {
|
|
1853
2836
|
try {
|
|
1854
2837
|
const rows = await this.engine.find("sys_user", {
|
|
1855
|
-
|
|
2838
|
+
where: { id: userId },
|
|
1856
2839
|
fields: ["id", "manager_id"],
|
|
1857
2840
|
limit: 1,
|
|
1858
|
-
context:
|
|
2841
|
+
context: SYSTEM_CTX2
|
|
1859
2842
|
});
|
|
1860
2843
|
const row = Array.isArray(rows) ? rows[0] : null;
|
|
1861
2844
|
return row?.manager_id ? String(row.manager_id) : null;
|
|
@@ -1902,10 +2885,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1902
2885
|
let rows = [];
|
|
1903
2886
|
try {
|
|
1904
2887
|
rows = await this.engine.find("sys_approval_delegation", {
|
|
1905
|
-
|
|
2888
|
+
where: { delegator_id: delegatorId },
|
|
1906
2889
|
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1907
2890
|
limit: 50,
|
|
1908
|
-
context:
|
|
2891
|
+
context: SYSTEM_CTX2
|
|
1909
2892
|
});
|
|
1910
2893
|
} catch {
|
|
1911
2894
|
return null;
|
|
@@ -1919,14 +2902,86 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1919
2902
|
});
|
|
1920
2903
|
return active[0];
|
|
1921
2904
|
}
|
|
1922
|
-
/**
|
|
1923
|
-
|
|
2905
|
+
/**
|
|
2906
|
+
* Mirror a request status onto a business-object field, if configured.
|
|
2907
|
+
*
|
|
2908
|
+
* **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
|
|
2909
|
+
* record is normally LOCKED while its approval is live and the submitter
|
|
2910
|
+
* cannot edit it, so only a platform write can land the status — that is what
|
|
2911
|
+
* the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
|
|
2912
|
+
* NOT do is throw away *who* caused the transition. Every status below is
|
|
2913
|
+
* something a specific human just did — a submitter submitting or recalling,
|
|
2914
|
+
* an approver deciding or sending back — and this write is what fires the
|
|
2915
|
+
* target object's record-change flows. With no `userId` on it those cascades
|
|
2916
|
+
* inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
|
|
2917
|
+
* user has its data ops REFUSED — so "when the invoice is approved, do X", the
|
|
2918
|
+
* most natural approvals automation there is, had to declare `runAs:'system'`
|
|
2919
|
+
* and take blanket elevation for a case where a perfectly good scoped identity
|
|
2920
|
+
* existed. Re-attaching the actor lets those cascades run as the deciding user
|
|
2921
|
+
* with RLS enforced. Same shape the approval node already uses when it calls
|
|
2922
|
+
* into this service (`approval-node.ts`).
|
|
2923
|
+
*
|
|
2924
|
+
* `actorId` is `null` for the genuinely machine-driven transitions (the SLA
|
|
2925
|
+
* escalation's auto-decision, the dead-run sweep). There is no human to name
|
|
2926
|
+
* there, and naming a sentinel would put a non-user in `updated_by` and in
|
|
2927
|
+
* every downstream flow's identity. Those cascades stay user-less — a flow
|
|
2928
|
+
* that wants to react to them still has to declare `runAs:'system'`, which is
|
|
2929
|
+
* the honest answer rather than an oversight.
|
|
2930
|
+
*
|
|
2931
|
+
* Deliberately carries `userId` ONLY, not the request's org. On an
|
|
2932
|
+
* ExecutionContext `tenantId` is a driver-scoping knob, not attribution
|
|
2933
|
+
* (`buildDriverOptions` turns it into a tenant predicate on the update), so
|
|
2934
|
+
* passing it would newly org-scope this write and silently no-op the mirror on
|
|
2935
|
+
* a record whose org differs from the request's — while buying nothing: the
|
|
2936
|
+
* automation engine back-fills the run's `tenantId` from the resolved user's
|
|
2937
|
+
* own grants.
|
|
2938
|
+
*/
|
|
2939
|
+
async mirrorStatusField(object, recordId, field, status, actorId) {
|
|
1924
2940
|
try {
|
|
1925
|
-
|
|
2941
|
+
const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
|
|
2942
|
+
await this.engine.update(object, { id: recordId, [field]: status }, { context });
|
|
1926
2943
|
} catch (err) {
|
|
1927
2944
|
this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
|
|
1928
2945
|
}
|
|
1929
2946
|
}
|
|
2947
|
+
/**
|
|
2948
|
+
* Re-read a business record's CURRENT state by id so approver resolution binds
|
|
2949
|
+
* to live data at node entry, not the trigger snapshot the flow froze into
|
|
2950
|
+
* `$record` at submit time (#3447). A `field` / `manager` approver names *who*
|
|
2951
|
+
* decides, and an earlier node — or the approver of an earlier step — may have
|
|
2952
|
+
* written that routing field after submit (e.g. a lead reviewer picking which
|
|
2953
|
+
* departments co-review). Graph approvers (team / position / …) already query
|
|
2954
|
+
* live; this brings the in-record types into line.
|
|
2955
|
+
*
|
|
2956
|
+
* Read under system identity: approver routing is a platform concern and the
|
|
2957
|
+
* record is the flow's own subject, so the submitter's RLS/FLS must not narrow
|
|
2958
|
+
* it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
|
|
2959
|
+
* hard-deleted between submit and node entry, or an object whose backend can't
|
|
2960
|
+
* serve a point read — warning rather than throwing so a transient miss can't
|
|
2961
|
+
* wedge an approval. That "warn but proceed" stance matches the
|
|
2962
|
+
* no-concrete-approver guard (#3424) and the "record is gone" enrichment path
|
|
2963
|
+
* that already falls back to the payload snapshot.
|
|
2964
|
+
*/
|
|
2965
|
+
async loadLiveRecord(object, recordId, fallback) {
|
|
2966
|
+
try {
|
|
2967
|
+
const rows = await this.engine.find(object, {
|
|
2968
|
+
where: { id: recordId },
|
|
2969
|
+
limit: 1,
|
|
2970
|
+
context: SYSTEM_CTX2
|
|
2971
|
+
});
|
|
2972
|
+
const live = Array.isArray(rows) ? rows[0] : rows;
|
|
2973
|
+
if (live) return live;
|
|
2974
|
+
this.logger?.warn?.(
|
|
2975
|
+
`[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
|
|
2976
|
+
{ object, recordId }
|
|
2977
|
+
);
|
|
2978
|
+
} catch (err) {
|
|
2979
|
+
this.logger?.warn?.(
|
|
2980
|
+
`[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
return fallback ?? {};
|
|
2984
|
+
}
|
|
1930
2985
|
// ── ADR-0019: Approval-as-flow-node ──────────────────────────
|
|
1931
2986
|
//
|
|
1932
2987
|
// A flow's Approval node opens a request via `openNodeRequest` (carrying its
|
|
@@ -1938,6 +2993,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1938
2993
|
* Open a pending approval request on behalf of a flow's Approval node. The
|
|
1939
2994
|
* node config (approvers / behavior / status field) is snapshotted on the row
|
|
1940
2995
|
* so a decision can be made without any process to resolve against.
|
|
2996
|
+
*
|
|
2997
|
+
* #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
|
|
2998
|
+
* request opened — when the slate resolves empty and the node's
|
|
2999
|
+
* `onEmptyApprovers` policy is `auto_approve`.
|
|
1941
3000
|
*/
|
|
1942
3001
|
async openNodeRequest(input, context) {
|
|
1943
3002
|
if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
|
|
@@ -1946,7 +3005,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1946
3005
|
const existing = await this.engine.find("sys_approval_request", {
|
|
1947
3006
|
where: { object_name: input.object, record_id: input.recordId, status: "pending" },
|
|
1948
3007
|
limit: 1,
|
|
1949
|
-
context:
|
|
3008
|
+
context: SYSTEM_CTX2
|
|
1950
3009
|
});
|
|
1951
3010
|
if (Array.isArray(existing) && existing[0]) {
|
|
1952
3011
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
@@ -1955,26 +3014,56 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1955
3014
|
const nowDate = this.clock.now();
|
|
1956
3015
|
const substitutions = [];
|
|
1957
3016
|
const groups = {};
|
|
3017
|
+
const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
|
|
3018
|
+
const resolvedFrom = {};
|
|
1958
3019
|
const approvers = await this.expandApprovers(
|
|
1959
3020
|
{ approvers: input.config.approvers },
|
|
1960
|
-
|
|
3021
|
+
liveRecord,
|
|
1961
3022
|
ctxOrg,
|
|
1962
|
-
{
|
|
3023
|
+
{
|
|
3024
|
+
now: nowDate.getTime(),
|
|
3025
|
+
substitutions,
|
|
3026
|
+
groups,
|
|
3027
|
+
exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
|
|
3028
|
+
resolvedFrom
|
|
3029
|
+
}
|
|
1963
3030
|
);
|
|
3031
|
+
if (!approvers.some((a) => a && !a.includes(":"))) {
|
|
3032
|
+
const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
|
|
3033
|
+
if (emptyPolicy === "fail") {
|
|
3034
|
+
throw new Error(
|
|
3035
|
+
`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.`
|
|
3036
|
+
);
|
|
3037
|
+
}
|
|
3038
|
+
if (emptyPolicy === "auto_approve") {
|
|
3039
|
+
this.logger?.warn?.(
|
|
3040
|
+
`[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).`,
|
|
3041
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
3042
|
+
);
|
|
3043
|
+
return { autoApproved: true, reason: "empty_approvers" };
|
|
3044
|
+
}
|
|
3045
|
+
this.logger?.warn?.(
|
|
3046
|
+
`[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.`,
|
|
3047
|
+
{ object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
|
|
3048
|
+
);
|
|
3049
|
+
}
|
|
1964
3050
|
const now = nowDate.toISOString();
|
|
1965
3051
|
const id = uid("areq");
|
|
1966
3052
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1967
3053
|
const configSnapshot = { ...input.config };
|
|
1968
3054
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1969
3055
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1970
|
-
if (input.config.behavior
|
|
3056
|
+
if (input.config.behavior && input.config.behavior !== "first_response") {
|
|
1971
3057
|
configSnapshot.__approverGroups = groups;
|
|
1972
3058
|
}
|
|
3059
|
+
if (Object.keys(resolvedFrom).length) {
|
|
3060
|
+
configSnapshot.__resolvedFrom = resolvedFrom;
|
|
3061
|
+
}
|
|
1973
3062
|
try {
|
|
1974
3063
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1975
3064
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
1976
3065
|
limit: 500,
|
|
1977
|
-
context:
|
|
3066
|
+
context: SYSTEM_CTX2
|
|
1978
3067
|
});
|
|
1979
3068
|
const n = Array.isArray(prior) ? prior.length : 0;
|
|
1980
3069
|
if (n > 0) configSnapshot.__round = n + 1;
|
|
@@ -1998,7 +3087,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1998
3087
|
created_at: now,
|
|
1999
3088
|
updated_at: now
|
|
2000
3089
|
};
|
|
2001
|
-
await this.engine.insert("sys_approval_request", row, { context:
|
|
3090
|
+
await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
|
|
2002
3091
|
await this.syncApproverIndex(id, approvers, ctxOrg, now);
|
|
2003
3092
|
await this.engine.insert("sys_approval_action", {
|
|
2004
3093
|
id: uid("aact"),
|
|
@@ -2010,7 +3099,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2010
3099
|
actor_id: input.submitterId ?? context.userId ?? null,
|
|
2011
3100
|
comment: null,
|
|
2012
3101
|
created_at: now
|
|
2013
|
-
}, { context:
|
|
3102
|
+
}, { context: SYSTEM_CTX2 });
|
|
2014
3103
|
for (const sub of substitutions) {
|
|
2015
3104
|
await this.engine.insert("sys_approval_action", {
|
|
2016
3105
|
id: uid("aact"),
|
|
@@ -2022,7 +3111,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2022
3111
|
actor_id: null,
|
|
2023
3112
|
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2024
3113
|
created_at: now
|
|
2025
|
-
}, { context:
|
|
3114
|
+
}, { context: SYSTEM_CTX2 });
|
|
2026
3115
|
await this.notify({
|
|
2027
3116
|
topic: "approval.ooo_substituted",
|
|
2028
3117
|
audience: [sub.to],
|
|
@@ -2047,7 +3136,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2047
3136
|
});
|
|
2048
3137
|
}
|
|
2049
3138
|
if (input.config.approvalStatusField) {
|
|
2050
|
-
await this.mirrorStatusField(
|
|
3139
|
+
await this.mirrorStatusField(
|
|
3140
|
+
input.object,
|
|
3141
|
+
input.recordId,
|
|
3142
|
+
input.config.approvalStatusField,
|
|
3143
|
+
"pending",
|
|
3144
|
+
row.submitter_id ?? null
|
|
3145
|
+
);
|
|
2051
3146
|
}
|
|
2052
3147
|
return rowFromRequest(row);
|
|
2053
3148
|
}
|
|
@@ -2091,27 +3186,61 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2091
3186
|
*/
|
|
2092
3187
|
async decideNode(requestId, input, context) {
|
|
2093
3188
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2094
|
-
|
|
3189
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2095
3190
|
if (input.decision !== "approve" && input.decision !== "reject") {
|
|
2096
3191
|
throw new Error("VALIDATION_FAILED: decision must be approve|reject");
|
|
2097
3192
|
}
|
|
2098
3193
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2099
3194
|
where: { id: requestId },
|
|
2100
3195
|
limit: 1,
|
|
2101
|
-
context:
|
|
3196
|
+
context: SYSTEM_CTX2
|
|
2102
3197
|
});
|
|
2103
3198
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2104
3199
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2105
3200
|
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2106
3201
|
const pendingApprovers = csvSplit(raw.pending_approvers);
|
|
2107
|
-
|
|
2108
|
-
|
|
3202
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3203
|
+
const isSlotHolder = pendingApprovers.includes(actorId);
|
|
3204
|
+
if (!isSlotHolder && !isOverride) {
|
|
3205
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2109
3206
|
}
|
|
2110
3207
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2111
3208
|
const org = raw.organization_id ?? null;
|
|
2112
3209
|
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
2113
3210
|
const runId = raw.flow_run_id ?? null;
|
|
2114
3211
|
const now = this.clock.now().toISOString();
|
|
3212
|
+
const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
|
|
3213
|
+
let acceptedOutputs;
|
|
3214
|
+
const declaredDefs = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs);
|
|
3215
|
+
if (outputKeys.length) {
|
|
3216
|
+
const declared = declaredDefs.map((d) => d.key);
|
|
3217
|
+
if (!declared.length) {
|
|
3218
|
+
throw new Error(
|
|
3219
|
+
`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.`
|
|
3220
|
+
);
|
|
3221
|
+
}
|
|
3222
|
+
const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
|
|
3223
|
+
if (reserved.length) {
|
|
3224
|
+
throw new Error(
|
|
3225
|
+
`VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
|
|
3226
|
+
);
|
|
3227
|
+
}
|
|
3228
|
+
const undeclared = outputKeys.filter((k) => !declared.includes(k));
|
|
3229
|
+
if (undeclared.length) {
|
|
3230
|
+
throw new Error(
|
|
3231
|
+
`VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
|
|
3232
|
+
);
|
|
3233
|
+
}
|
|
3234
|
+
acceptedOutputs = { ...input.outputs };
|
|
3235
|
+
}
|
|
3236
|
+
if (input.decision === "approve") {
|
|
3237
|
+
const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
|
|
3238
|
+
if (missing.length) {
|
|
3239
|
+
throw new Error(
|
|
3240
|
+
`VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
}
|
|
2115
3244
|
await this.engine.insert("sys_approval_action", {
|
|
2116
3245
|
id: uid("aact"),
|
|
2117
3246
|
request_id: requestId,
|
|
@@ -2119,23 +3248,23 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2119
3248
|
step_name: nodeId,
|
|
2120
3249
|
step_index: 0,
|
|
2121
3250
|
action: input.decision,
|
|
2122
|
-
actor_id:
|
|
3251
|
+
actor_id: actorId,
|
|
2123
3252
|
comment: input.comment ?? null,
|
|
2124
3253
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2125
3254
|
created_at: now
|
|
2126
|
-
}, { context:
|
|
3255
|
+
}, { context: SYSTEM_CTX2 });
|
|
2127
3256
|
const behavior = config.behavior ?? "first_response";
|
|
2128
|
-
if (input.decision === "approve" && behavior !== "first_response") {
|
|
3257
|
+
if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
|
|
2129
3258
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2130
3259
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
2131
3260
|
limit: 1e3,
|
|
2132
|
-
context:
|
|
3261
|
+
context: SYSTEM_CTX2
|
|
2133
3262
|
});
|
|
2134
3263
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
2135
3264
|
const snapshotGroups = config.__approverGroups;
|
|
2136
3265
|
let original;
|
|
2137
3266
|
let groupMap;
|
|
2138
|
-
if (snapshotGroups
|
|
3267
|
+
if (snapshotGroups) {
|
|
2139
3268
|
groupMap = snapshotGroups;
|
|
2140
3269
|
original = Object.keys(snapshotGroups);
|
|
2141
3270
|
} else {
|
|
@@ -2151,27 +3280,65 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2151
3280
|
await this.engine.update("sys_approval_request", {
|
|
2152
3281
|
id: requestId,
|
|
2153
3282
|
pending_approvers: stillPending.join(","),
|
|
2154
|
-
updated_at: now
|
|
2155
|
-
|
|
3283
|
+
updated_at: now,
|
|
3284
|
+
// #3447 P2: a mid-tally approval may carry outputs too (unanimous /
|
|
3285
|
+
// per_group co-sign, each approver contributing their declared keys)
|
|
3286
|
+
// — accumulate them on the snapshot so the FINALIZING decision hands
|
|
3287
|
+
// the merged set to the flow.
|
|
3288
|
+
...acceptedOutputs ? {
|
|
3289
|
+
node_config_json: JSON.stringify({
|
|
3290
|
+
...config,
|
|
3291
|
+
__decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
|
|
3292
|
+
})
|
|
3293
|
+
} : {}
|
|
3294
|
+
}, { context: SYSTEM_CTX2 });
|
|
2156
3295
|
await this.syncApproverIndex(requestId, stillPending, org, now);
|
|
2157
|
-
const fresh2 = await this.
|
|
3296
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2158
3297
|
return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
|
|
2159
3298
|
}
|
|
2160
3299
|
}
|
|
2161
3300
|
const finalStatus = input.decision === "approve" ? "approved" : "rejected";
|
|
3301
|
+
const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
|
|
2162
3302
|
await this.engine.update("sys_approval_request", {
|
|
2163
3303
|
id: requestId,
|
|
2164
3304
|
status: finalStatus,
|
|
2165
3305
|
pending_approvers: null,
|
|
2166
3306
|
completed_at: now,
|
|
2167
|
-
updated_at: now
|
|
2168
|
-
|
|
3307
|
+
updated_at: now,
|
|
3308
|
+
...mergedOutputs ? {
|
|
3309
|
+
node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
|
|
3310
|
+
} : {}
|
|
3311
|
+
}, { context: SYSTEM_CTX2 });
|
|
2169
3312
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2170
3313
|
if (config.approvalStatusField) {
|
|
2171
|
-
await this.mirrorStatusField(
|
|
3314
|
+
await this.mirrorStatusField(
|
|
3315
|
+
raw.object_name,
|
|
3316
|
+
raw.record_id,
|
|
3317
|
+
config.approvalStatusField,
|
|
3318
|
+
finalStatus,
|
|
3319
|
+
actingUserId(context)
|
|
3320
|
+
);
|
|
2172
3321
|
}
|
|
2173
|
-
const fresh = await this.
|
|
2174
|
-
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
|
|
3322
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
3323
|
+
return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
|
|
3324
|
+
}
|
|
3325
|
+
/**
|
|
3326
|
+
* Continue the owning flow run after an outcome this service has already
|
|
3327
|
+
* authorized and written down (#3801).
|
|
3328
|
+
*
|
|
3329
|
+
* The `approval` node declares `resumeAuthority: 'service'`, so the engine
|
|
3330
|
+
* refuses any resume of an approval suspension that does not carry
|
|
3331
|
+
* {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
|
|
3332
|
+
* here so the marker is stamped in ONE place — a new outcome path cannot
|
|
3333
|
+
* quietly ship a resume that the gate then rejects at runtime, and nothing
|
|
3334
|
+
* in this file hands the marker to a caller-supplied signal.
|
|
3335
|
+
*
|
|
3336
|
+
* Callers still guard on `typeof this.automation?.resume === 'function'`
|
|
3337
|
+
* (approvals runs fine with no automation attached) and keep their own
|
|
3338
|
+
* try/catch, because what a failed resume means differs per path.
|
|
3339
|
+
*/
|
|
3340
|
+
async serviceResume(runId, signal) {
|
|
3341
|
+
await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
|
|
2175
3342
|
}
|
|
2176
3343
|
/**
|
|
2177
3344
|
* Public contract entrypoint (ADR-0019). Records a decision on a node-driven
|
|
@@ -2184,9 +3351,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2184
3351
|
if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
|
|
2185
3352
|
const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
|
|
2186
3353
|
try {
|
|
2187
|
-
await this.
|
|
3354
|
+
await this.serviceResume(result.runId, {
|
|
2188
3355
|
branchLabel,
|
|
2189
|
-
|
|
3356
|
+
// #3447 P2: accepted decision outputs ride the resume envelope and
|
|
3357
|
+
// land as `<nodeId>.<key>` flow variables — a later approval node's
|
|
3358
|
+
// `expression` approver reads them as `vars.<nodeId>.<key>`.
|
|
3359
|
+
// Reserved keys are spread LAST so no output can shadow them (the
|
|
3360
|
+
// whitelist already rejects them; this is defense in depth).
|
|
3361
|
+
output: { ...result.outputs ?? {}, decision: result.decision, requestId }
|
|
2190
3362
|
});
|
|
2191
3363
|
resumed = true;
|
|
2192
3364
|
} catch (err) {
|
|
@@ -2220,11 +3392,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2220
3392
|
*/
|
|
2221
3393
|
async recall(requestId, input, context) {
|
|
2222
3394
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
2223
|
-
|
|
3395
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2224
3396
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2225
3397
|
where: { id: requestId },
|
|
2226
3398
|
limit: 1,
|
|
2227
|
-
context:
|
|
3399
|
+
context: SYSTEM_CTX2
|
|
2228
3400
|
});
|
|
2229
3401
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2230
3402
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
@@ -2232,7 +3404,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2232
3404
|
if (raw.status !== "pending" && !inReviseWindow) {
|
|
2233
3405
|
throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
2234
3406
|
}
|
|
2235
|
-
if (!context.
|
|
3407
|
+
if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2236
3408
|
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
2237
3409
|
}
|
|
2238
3410
|
if (inReviseWindow) await this.assertLatestForRun(raw);
|
|
@@ -2248,20 +3420,26 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2248
3420
|
step_name: nodeId,
|
|
2249
3421
|
step_index: 0,
|
|
2250
3422
|
action: "recall",
|
|
2251
|
-
actor_id:
|
|
3423
|
+
actor_id: actorId,
|
|
2252
3424
|
comment: input.comment ?? null,
|
|
2253
3425
|
created_at: now
|
|
2254
|
-
}, { context:
|
|
3426
|
+
}, { context: SYSTEM_CTX2 });
|
|
2255
3427
|
await this.engine.update("sys_approval_request", {
|
|
2256
3428
|
id: requestId,
|
|
2257
3429
|
status: "recalled",
|
|
2258
3430
|
pending_approvers: null,
|
|
2259
3431
|
completed_at: now,
|
|
2260
3432
|
updated_at: now
|
|
2261
|
-
}, { context:
|
|
3433
|
+
}, { context: SYSTEM_CTX2 });
|
|
2262
3434
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2263
3435
|
if (config.approvalStatusField) {
|
|
2264
|
-
await this.mirrorStatusField(
|
|
3436
|
+
await this.mirrorStatusField(
|
|
3437
|
+
raw.object_name,
|
|
3438
|
+
raw.record_id,
|
|
3439
|
+
config.approvalStatusField,
|
|
3440
|
+
"recalled",
|
|
3441
|
+
actingUserId(context)
|
|
3442
|
+
);
|
|
2265
3443
|
}
|
|
2266
3444
|
let resumed = false;
|
|
2267
3445
|
if (inReviseWindow) {
|
|
@@ -2278,7 +3456,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2278
3456
|
}
|
|
2279
3457
|
} else if (runId && typeof this.automation?.resume === "function") {
|
|
2280
3458
|
try {
|
|
2281
|
-
await this.
|
|
3459
|
+
await this.serviceResume(runId, {
|
|
2282
3460
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
|
|
2283
3461
|
output: { decision: "recall", requestId }
|
|
2284
3462
|
});
|
|
@@ -2291,7 +3469,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2291
3469
|
});
|
|
2292
3470
|
}
|
|
2293
3471
|
}
|
|
2294
|
-
const fresh = await this.
|
|
3472
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2295
3473
|
return { request: fresh, runId, resumed };
|
|
2296
3474
|
}
|
|
2297
3475
|
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
@@ -2309,11 +3487,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2309
3487
|
* `output.autoRejected = true`) so instances cannot orbit forever.
|
|
2310
3488
|
*/
|
|
2311
3489
|
async sendBack(requestId, input, context) {
|
|
2312
|
-
|
|
3490
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2313
3491
|
const raw = await this.loadPendingRow(requestId);
|
|
2314
3492
|
const pending = csvSplit(raw.pending_approvers);
|
|
2315
|
-
if (!context.isSystem && !pending.includes(
|
|
2316
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
3493
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
3494
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2317
3495
|
}
|
|
2318
3496
|
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
2319
3497
|
const org = raw.organization_id ?? null;
|
|
@@ -2327,7 +3505,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2327
3505
|
const siblings = await this.engine.find("sys_approval_request", {
|
|
2328
3506
|
where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
|
|
2329
3507
|
limit: 500,
|
|
2330
|
-
context:
|
|
3508
|
+
context: SYSTEM_CTX2
|
|
2331
3509
|
});
|
|
2332
3510
|
priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
|
|
2333
3511
|
}
|
|
@@ -2338,10 +3516,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2338
3516
|
step_name: nodeId,
|
|
2339
3517
|
step_index: 0,
|
|
2340
3518
|
action: "revise",
|
|
2341
|
-
actor_id:
|
|
3519
|
+
actor_id: actorId,
|
|
2342
3520
|
comment: input.comment ?? null,
|
|
2343
3521
|
created_at: now
|
|
2344
|
-
}, { context:
|
|
3522
|
+
}, { context: SYSTEM_CTX2 });
|
|
2345
3523
|
if (priorSendBacks >= maxRevisions) {
|
|
2346
3524
|
await this.engine.insert("sys_approval_action", {
|
|
2347
3525
|
id: uid("aact"),
|
|
@@ -2350,25 +3528,31 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2350
3528
|
step_name: nodeId,
|
|
2351
3529
|
step_index: 0,
|
|
2352
3530
|
action: "reject",
|
|
2353
|
-
actor_id:
|
|
3531
|
+
actor_id: actorId,
|
|
2354
3532
|
comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
|
|
2355
3533
|
created_at: now
|
|
2356
|
-
}, { context:
|
|
3534
|
+
}, { context: SYSTEM_CTX2 });
|
|
2357
3535
|
await this.engine.update("sys_approval_request", {
|
|
2358
3536
|
id: requestId,
|
|
2359
3537
|
status: "rejected",
|
|
2360
3538
|
pending_approvers: null,
|
|
2361
3539
|
completed_at: now,
|
|
2362
3540
|
updated_at: now
|
|
2363
|
-
}, { context:
|
|
3541
|
+
}, { context: SYSTEM_CTX2 });
|
|
2364
3542
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2365
3543
|
if (config.approvalStatusField) {
|
|
2366
|
-
await this.mirrorStatusField(
|
|
3544
|
+
await this.mirrorStatusField(
|
|
3545
|
+
raw.object_name,
|
|
3546
|
+
raw.record_id,
|
|
3547
|
+
config.approvalStatusField,
|
|
3548
|
+
"rejected",
|
|
3549
|
+
actingUserId(context)
|
|
3550
|
+
);
|
|
2367
3551
|
}
|
|
2368
3552
|
let resumed2 = false;
|
|
2369
3553
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2370
3554
|
try {
|
|
2371
|
-
await this.
|
|
3555
|
+
await this.serviceResume(runId, {
|
|
2372
3556
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
|
|
2373
3557
|
output: { decision: "reject", autoRejected: true, requestId }
|
|
2374
3558
|
});
|
|
@@ -2385,7 +3569,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2385
3569
|
await this.notify({
|
|
2386
3570
|
topic: "approval.returned",
|
|
2387
3571
|
audience: [String(raw.submitter_id)],
|
|
2388
|
-
actorId
|
|
3572
|
+
actorId,
|
|
2389
3573
|
source: { object: "sys_approval_request", id: requestId },
|
|
2390
3574
|
payload: {
|
|
2391
3575
|
title: "Approval auto-rejected",
|
|
@@ -2394,7 +3578,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2394
3578
|
}
|
|
2395
3579
|
});
|
|
2396
3580
|
}
|
|
2397
|
-
const fresh2 = await this.
|
|
3581
|
+
const fresh2 = await this.readBackRequest(requestId, context);
|
|
2398
3582
|
return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
|
|
2399
3583
|
}
|
|
2400
3584
|
await this.engine.update("sys_approval_request", {
|
|
@@ -2403,15 +3587,21 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2403
3587
|
pending_approvers: null,
|
|
2404
3588
|
completed_at: now,
|
|
2405
3589
|
updated_at: now
|
|
2406
|
-
}, { context:
|
|
3590
|
+
}, { context: SYSTEM_CTX2 });
|
|
2407
3591
|
await this.syncApproverIndex(requestId, [], org, now);
|
|
2408
3592
|
if (config.approvalStatusField) {
|
|
2409
|
-
await this.mirrorStatusField(
|
|
3593
|
+
await this.mirrorStatusField(
|
|
3594
|
+
raw.object_name,
|
|
3595
|
+
raw.record_id,
|
|
3596
|
+
config.approvalStatusField,
|
|
3597
|
+
"returned",
|
|
3598
|
+
actingUserId(context)
|
|
3599
|
+
);
|
|
2410
3600
|
}
|
|
2411
3601
|
let resumed = false;
|
|
2412
3602
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2413
3603
|
try {
|
|
2414
|
-
await this.
|
|
3604
|
+
await this.serviceResume(runId, {
|
|
2415
3605
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
|
|
2416
3606
|
output: { decision: "revise", requestId }
|
|
2417
3607
|
});
|
|
@@ -2428,7 +3618,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2428
3618
|
await this.notify({
|
|
2429
3619
|
topic: "approval.returned",
|
|
2430
3620
|
audience: [String(raw.submitter_id)],
|
|
2431
|
-
actorId
|
|
3621
|
+
actorId,
|
|
2432
3622
|
source: { object: "sys_approval_request", id: requestId },
|
|
2433
3623
|
payload: {
|
|
2434
3624
|
title: "Sent back for revision",
|
|
@@ -2437,7 +3627,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2437
3627
|
}
|
|
2438
3628
|
});
|
|
2439
3629
|
}
|
|
2440
|
-
const fresh = await this.
|
|
3630
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2441
3631
|
return { request: fresh, runId, resumed };
|
|
2442
3632
|
}
|
|
2443
3633
|
/**
|
|
@@ -2448,25 +3638,25 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2448
3638
|
* round-N+1 request — fresh approver slate, record re-locks.
|
|
2449
3639
|
*/
|
|
2450
3640
|
async resubmit(requestId, input, context) {
|
|
2451
|
-
|
|
3641
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2452
3642
|
const rawRows = await this.engine.find("sys_approval_request", {
|
|
2453
3643
|
where: { id: requestId },
|
|
2454
3644
|
limit: 1,
|
|
2455
|
-
context:
|
|
3645
|
+
context: SYSTEM_CTX2
|
|
2456
3646
|
});
|
|
2457
3647
|
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
2458
3648
|
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
2459
3649
|
if (raw.status !== "returned") {
|
|
2460
3650
|
throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
|
|
2461
3651
|
}
|
|
2462
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3652
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2463
3653
|
throw new Error("FORBIDDEN: only the submitter may resubmit");
|
|
2464
3654
|
}
|
|
2465
3655
|
await this.assertLatestForRun(raw);
|
|
2466
3656
|
const colliding = await this.engine.find("sys_approval_request", {
|
|
2467
3657
|
where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
|
|
2468
3658
|
limit: 1,
|
|
2469
|
-
context:
|
|
3659
|
+
context: SYSTEM_CTX2
|
|
2470
3660
|
});
|
|
2471
3661
|
if (Array.isArray(colliding) && colliding[0]) {
|
|
2472
3662
|
throw new Error(
|
|
@@ -2484,14 +3674,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2484
3674
|
step_name: nodeId,
|
|
2485
3675
|
step_index: 0,
|
|
2486
3676
|
action: "resubmit",
|
|
2487
|
-
actor_id:
|
|
3677
|
+
actor_id: actorId,
|
|
2488
3678
|
comment: input.comment ?? null,
|
|
2489
3679
|
created_at: now
|
|
2490
|
-
}, { context:
|
|
3680
|
+
}, { context: SYSTEM_CTX2 });
|
|
2491
3681
|
let resumed = false;
|
|
2492
3682
|
if (runId && typeof this.automation?.resume === "function") {
|
|
2493
3683
|
try {
|
|
2494
|
-
await this.
|
|
3684
|
+
await this.serviceResume(runId, {
|
|
2495
3685
|
branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
|
|
2496
3686
|
output: { resubmitted: true, requestId }
|
|
2497
3687
|
});
|
|
@@ -2504,7 +3694,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2504
3694
|
});
|
|
2505
3695
|
}
|
|
2506
3696
|
}
|
|
2507
|
-
const fresh = await this.
|
|
3697
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2508
3698
|
return { request: fresh, runId, resumed };
|
|
2509
3699
|
}
|
|
2510
3700
|
/**
|
|
@@ -2539,7 +3729,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2539
3729
|
where: { flow_run_id: runId },
|
|
2540
3730
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2541
3731
|
limit: 1,
|
|
2542
|
-
context:
|
|
3732
|
+
context: SYSTEM_CTX2
|
|
2543
3733
|
});
|
|
2544
3734
|
const latest = Array.isArray(rows) ? rows[0] : null;
|
|
2545
3735
|
if (latest && String(latest.id) !== String(raw.id)) {
|
|
@@ -2550,25 +3740,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2550
3740
|
/**
|
|
2551
3741
|
* Hand a pending-approver slot to someone else. `from` defaults to the
|
|
2552
3742
|
* actor itself; the actor must hold the slot being handed over (or be a
|
|
2553
|
-
* system caller).
|
|
3743
|
+
* system caller). A privileged admin (#3424) may reassign a request whose
|
|
3744
|
+
* slate holds no real user — an unstaffed-position literal — by handing the
|
|
3745
|
+
* whole request to a real approver, rescuing it from the locked dead-end.
|
|
3746
|
+
* Audits `reassign` and notifies the new approver.
|
|
2554
3747
|
*/
|
|
2555
3748
|
async reassign(requestId, input, context) {
|
|
2556
|
-
|
|
3749
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2557
3750
|
const to = String(input?.to ?? "").trim();
|
|
2558
3751
|
if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
|
|
2559
3752
|
const raw = await this.loadPendingRow(requestId);
|
|
2560
3753
|
const pending = csvSplit(raw.pending_approvers);
|
|
2561
|
-
const from = String(input.from ?? input.actorId).trim();
|
|
2562
|
-
if (!pending.includes(from)) {
|
|
2563
|
-
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
2564
|
-
}
|
|
2565
|
-
if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
|
|
2566
|
-
throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
|
|
2567
|
-
}
|
|
2568
3754
|
if (pending.includes(to)) {
|
|
2569
3755
|
throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
|
|
2570
3756
|
}
|
|
2571
|
-
const
|
|
3757
|
+
const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
|
|
3758
|
+
const from = String(input.from ?? actorId).trim();
|
|
3759
|
+
let next;
|
|
3760
|
+
if (pending.includes(from)) {
|
|
3761
|
+
if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
|
|
3762
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
3763
|
+
}
|
|
3764
|
+
next = pending.map((a) => a === from ? to : a);
|
|
3765
|
+
} else if (isOverride) {
|
|
3766
|
+
next = [to];
|
|
3767
|
+
} else {
|
|
3768
|
+
throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
|
|
3769
|
+
}
|
|
2572
3770
|
const now = this.clock.now().toISOString();
|
|
2573
3771
|
await this.engine.insert("sys_approval_action", {
|
|
2574
3772
|
id: uid("aact"),
|
|
@@ -2577,10 +3775,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2577
3775
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2578
3776
|
step_index: 0,
|
|
2579
3777
|
action: "reassign",
|
|
2580
|
-
|
|
2581
|
-
|
|
3778
|
+
// The hand-off parties are STRUCTURED fields (#4365) — the old default
|
|
3779
|
+
// comment (`"<from> → <to>"`) baked raw user ids into user-facing text.
|
|
3780
|
+
// `comment` is pure user input: absent unless the actor wrote one.
|
|
3781
|
+
actor_id: actorId,
|
|
3782
|
+
reassign_from: from,
|
|
3783
|
+
reassign_to: to,
|
|
3784
|
+
comment: input.comment ?? null,
|
|
2582
3785
|
created_at: now
|
|
2583
|
-
}, { context:
|
|
3786
|
+
}, { context: SYSTEM_CTX2 });
|
|
2584
3787
|
let configPatch = {};
|
|
2585
3788
|
try {
|
|
2586
3789
|
const cfg = parseJson(raw.node_config_json, null);
|
|
@@ -2597,12 +3800,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2597
3800
|
pending_approvers: next.join(","),
|
|
2598
3801
|
updated_at: now,
|
|
2599
3802
|
...configPatch
|
|
2600
|
-
}, { context:
|
|
3803
|
+
}, { context: SYSTEM_CTX2 });
|
|
2601
3804
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
2602
3805
|
await this.notify({
|
|
2603
3806
|
topic: "approval.reassigned",
|
|
2604
3807
|
audience: [to],
|
|
2605
|
-
actorId
|
|
3808
|
+
actorId,
|
|
2606
3809
|
source: { object: "sys_approval_request", id: requestId },
|
|
2607
3810
|
dedupKey: `approval-reassign-${requestId}-${to}`,
|
|
2608
3811
|
payload: {
|
|
@@ -2611,7 +3814,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2611
3814
|
actionUrl: "/system/approvals"
|
|
2612
3815
|
}
|
|
2613
3816
|
});
|
|
2614
|
-
const fresh = await this.
|
|
3817
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2615
3818
|
return { request: fresh };
|
|
2616
3819
|
}
|
|
2617
3820
|
/**
|
|
@@ -2619,16 +3822,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2619
3822
|
* reminder per {@link REMIND_COOLDOWN_MS} per request.
|
|
2620
3823
|
*/
|
|
2621
3824
|
async remind(requestId, input, context) {
|
|
2622
|
-
|
|
3825
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2623
3826
|
const raw = await this.loadPendingRow(requestId);
|
|
2624
|
-
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(
|
|
3827
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
|
|
2625
3828
|
throw new Error("FORBIDDEN: only the submitter may send reminders");
|
|
2626
3829
|
}
|
|
2627
3830
|
const acts = await this.engine.find("sys_approval_action", {
|
|
2628
3831
|
where: { request_id: requestId, action: "remind" },
|
|
2629
3832
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
2630
3833
|
limit: 1,
|
|
2631
|
-
context:
|
|
3834
|
+
context: SYSTEM_CTX2
|
|
2632
3835
|
});
|
|
2633
3836
|
const last = Array.isArray(acts) ? acts[0] : null;
|
|
2634
3837
|
const now = this.clock.now();
|
|
@@ -2644,10 +3847,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2644
3847
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2645
3848
|
step_index: 0,
|
|
2646
3849
|
action: "remind",
|
|
2647
|
-
actor_id:
|
|
3850
|
+
actor_id: actorId,
|
|
2648
3851
|
comment: input.comment ?? null,
|
|
2649
3852
|
created_at: nowIso
|
|
2650
|
-
}, { context:
|
|
3853
|
+
}, { context: SYSTEM_CTX2 });
|
|
2651
3854
|
let notified = 0;
|
|
2652
3855
|
const concrete = pending.filter((a) => a && !a.includes(":"));
|
|
2653
3856
|
const literals = pending.filter((a) => a && a.includes(":"));
|
|
@@ -2657,7 +3860,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2657
3860
|
notified += await this.notify({
|
|
2658
3861
|
topic: "approval.reminder",
|
|
2659
3862
|
audience: [approver],
|
|
2660
|
-
actorId
|
|
3863
|
+
actorId,
|
|
2661
3864
|
source: { object: "sys_approval_request", id: requestId },
|
|
2662
3865
|
dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
|
|
2663
3866
|
payload: {
|
|
@@ -2682,7 +3885,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2682
3885
|
notified += await this.notify({
|
|
2683
3886
|
topic: "approval.reminder",
|
|
2684
3887
|
audience: literals,
|
|
2685
|
-
actorId
|
|
3888
|
+
actorId,
|
|
2686
3889
|
source: { object: "sys_approval_request", id: requestId },
|
|
2687
3890
|
dedupKey: `approval-remind-${requestId}-${nowIso}`,
|
|
2688
3891
|
payload: {
|
|
@@ -2692,7 +3895,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2692
3895
|
}
|
|
2693
3896
|
});
|
|
2694
3897
|
}
|
|
2695
|
-
const fresh = await this.
|
|
3898
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2696
3899
|
return { request: fresh, notified };
|
|
2697
3900
|
}
|
|
2698
3901
|
// ── Actionable links (ADR-0043) ──────────────────────────────
|
|
@@ -2727,7 +3930,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2727
3930
|
expires_at: expires,
|
|
2728
3931
|
consumed_at: null,
|
|
2729
3932
|
created_at: now.toISOString()
|
|
2730
|
-
}, { context:
|
|
3933
|
+
}, { context: SYSTEM_CTX2 });
|
|
2731
3934
|
out[action] = rawToken;
|
|
2732
3935
|
}
|
|
2733
3936
|
return out;
|
|
@@ -2740,7 +3943,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2740
3943
|
const rows = await this.engine.find("sys_approval_token", {
|
|
2741
3944
|
where: { token_hash: hash },
|
|
2742
3945
|
limit: 1,
|
|
2743
|
-
context:
|
|
3946
|
+
context: SYSTEM_CTX2
|
|
2744
3947
|
});
|
|
2745
3948
|
const token = Array.isArray(rows) ? rows[0] : null;
|
|
2746
3949
|
if (!token) return { ok: false, reason: "invalid" };
|
|
@@ -2748,7 +3951,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2748
3951
|
if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
|
|
2749
3952
|
return { ok: false, reason: "expired" };
|
|
2750
3953
|
}
|
|
2751
|
-
const request = await this.getRequest(token.request_id,
|
|
3954
|
+
const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
|
|
2752
3955
|
if (!request || request.status !== "pending") {
|
|
2753
3956
|
return { ok: false, reason: "not_pending", request: request ?? void 0 };
|
|
2754
3957
|
}
|
|
@@ -2773,12 +3976,19 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2773
3976
|
await this.engine.update("sys_approval_token", {
|
|
2774
3977
|
id: res.token.id,
|
|
2775
3978
|
consumed_at: this.clock.now().toISOString()
|
|
2776
|
-
}, { context:
|
|
3979
|
+
}, { context: SYSTEM_CTX2 });
|
|
2777
3980
|
const out = await this.decide(res.token.request_id, {
|
|
2778
3981
|
decision: res.token.action,
|
|
2779
3982
|
actorId: res.token.approver_id,
|
|
2780
3983
|
comment: "Via action link"
|
|
2781
|
-
|
|
3984
|
+
// The token IS the authentication (#3783): it is single-use, hashed at
|
|
3985
|
+
// rest and bound to one approver, who `resolveActionToken` has just
|
|
3986
|
+
// re-checked still holds a pending slot. So this decision has a real
|
|
3987
|
+
// acting user even though no session carried it — name them on the
|
|
3988
|
+
// context, so the status mirror and every flow it cascades into are
|
|
3989
|
+
// attributed exactly like a decision made through the UI. Elevation is
|
|
3990
|
+
// unchanged: `isSystem` still stands in for the missing session.
|
|
3991
|
+
}, { ...SYSTEM_CTX2, userId: res.token.approver_id });
|
|
2782
3992
|
return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
|
|
2783
3993
|
}
|
|
2784
3994
|
/**
|
|
@@ -2786,12 +3996,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2786
3996
|
* pending — a thread interaction, not a flow decision.
|
|
2787
3997
|
*/
|
|
2788
3998
|
async requestInfo(requestId, input, context) {
|
|
2789
|
-
|
|
3999
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2790
4000
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2791
4001
|
const raw = await this.loadPendingRow(requestId);
|
|
2792
4002
|
const pending = csvSplit(raw.pending_approvers);
|
|
2793
|
-
if (!context.isSystem && !pending.includes(
|
|
2794
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
4003
|
+
if (!context.isSystem && !pending.includes(actorId)) {
|
|
4004
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
|
|
2795
4005
|
}
|
|
2796
4006
|
const now = this.clock.now().toISOString();
|
|
2797
4007
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2801,15 +4011,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2801
4011
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2802
4012
|
step_index: 0,
|
|
2803
4013
|
action: "request_info",
|
|
2804
|
-
actor_id:
|
|
4014
|
+
actor_id: actorId,
|
|
2805
4015
|
comment: input.comment.trim(),
|
|
2806
4016
|
created_at: now
|
|
2807
|
-
}, { context:
|
|
4017
|
+
}, { context: SYSTEM_CTX2 });
|
|
2808
4018
|
if (raw.submitter_id) {
|
|
2809
4019
|
await this.notify({
|
|
2810
4020
|
topic: "approval.request_info",
|
|
2811
4021
|
audience: [String(raw.submitter_id)],
|
|
2812
|
-
actorId
|
|
4022
|
+
actorId,
|
|
2813
4023
|
source: { object: "sys_approval_request", id: requestId },
|
|
2814
4024
|
payload: {
|
|
2815
4025
|
title: "More information requested",
|
|
@@ -2818,18 +4028,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2818
4028
|
}
|
|
2819
4029
|
});
|
|
2820
4030
|
}
|
|
2821
|
-
const fresh = await this.
|
|
4031
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2822
4032
|
return { request: fresh };
|
|
2823
4033
|
}
|
|
2824
4034
|
/** Free-form reply on the thread (submitter or any pending approver). */
|
|
2825
4035
|
async comment(requestId, input, context) {
|
|
2826
|
-
|
|
4036
|
+
const actorId = await this.resolveActor(input?.actorId, context);
|
|
2827
4037
|
if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
|
|
2828
4038
|
const raw = await this.loadPendingRow(requestId);
|
|
2829
4039
|
const pending = csvSplit(raw.pending_approvers);
|
|
2830
|
-
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(
|
|
2831
|
-
if (!context.isSystem && !isSubmitter && !pending.includes(
|
|
2832
|
-
throw new Error(`FORBIDDEN: actor '${
|
|
4040
|
+
const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
|
|
4041
|
+
if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
|
|
4042
|
+
throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
|
|
2833
4043
|
}
|
|
2834
4044
|
const now = this.clock.now().toISOString();
|
|
2835
4045
|
await this.engine.insert("sys_approval_action", {
|
|
@@ -2839,16 +4049,16 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2839
4049
|
step_name: raw.flow_node_id ?? raw.current_step ?? null,
|
|
2840
4050
|
step_index: 0,
|
|
2841
4051
|
action: "comment",
|
|
2842
|
-
actor_id:
|
|
4052
|
+
actor_id: actorId,
|
|
2843
4053
|
comment: input.comment.trim(),
|
|
2844
4054
|
attachments: input.attachments?.length ? input.attachments : null,
|
|
2845
4055
|
created_at: now
|
|
2846
|
-
}, { context:
|
|
4056
|
+
}, { context: SYSTEM_CTX2 });
|
|
2847
4057
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
2848
4058
|
await this.notify({
|
|
2849
4059
|
topic: "approval.comment",
|
|
2850
4060
|
audience,
|
|
2851
|
-
actorId
|
|
4061
|
+
actorId,
|
|
2852
4062
|
source: { object: "sys_approval_request", id: requestId },
|
|
2853
4063
|
payload: {
|
|
2854
4064
|
title: "New comment on an approval",
|
|
@@ -2856,7 +4066,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2856
4066
|
actionUrl: "/system/approvals"
|
|
2857
4067
|
}
|
|
2858
4068
|
});
|
|
2859
|
-
const fresh = await this.
|
|
4069
|
+
const fresh = await this.readBackRequest(requestId, context);
|
|
2860
4070
|
return { request: fresh };
|
|
2861
4071
|
}
|
|
2862
4072
|
// ── SLA escalation (ADR-0042) ─────────────────────────────────
|
|
@@ -2873,7 +4083,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2873
4083
|
rows = await this.engine.find("sys_approval_request", {
|
|
2874
4084
|
where: { status: "pending" },
|
|
2875
4085
|
limit: 500,
|
|
2876
|
-
context:
|
|
4086
|
+
context: SYSTEM_CTX2
|
|
2877
4087
|
}) ?? [];
|
|
2878
4088
|
} catch (err) {
|
|
2879
4089
|
this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
|
|
@@ -2892,7 +4102,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2892
4102
|
const prior = await this.engine.find("sys_approval_action", {
|
|
2893
4103
|
where: { request_id: raw.id, action: "escalate" },
|
|
2894
4104
|
limit: 1,
|
|
2895
|
-
context:
|
|
4105
|
+
context: SYSTEM_CTX2
|
|
2896
4106
|
});
|
|
2897
4107
|
if (Array.isArray(prior) && prior[0]) continue;
|
|
2898
4108
|
await this.escalateRequest(raw, esc2);
|
|
@@ -2909,6 +4119,130 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2909
4119
|
}
|
|
2910
4120
|
return { scanned: rows.length, escalated };
|
|
2911
4121
|
}
|
|
4122
|
+
// ── Dead-run release (#3456) ──────────────────────────────────
|
|
4123
|
+
/**
|
|
4124
|
+
* One dead-run sweep: a pending request whose owning flow run has reached a
|
|
4125
|
+
* TERMINAL state can never be decided — nothing is left to resume — so the
|
|
4126
|
+
* request is finalised as `recalled` and, with `lockRecord`, the record it was
|
|
4127
|
+
* holding is released.
|
|
4128
|
+
*
|
|
4129
|
+
* This is the recovery half of #3456. The prevention half is the record lock's
|
|
4130
|
+
* owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
|
|
4131
|
+
* itself on its own lock in the first place; this sweep cleans up the runs that
|
|
4132
|
+
* still die — for any reason, including a process crash, which no in-band
|
|
4133
|
+
* handler can catch because the process that would have run it is gone.
|
|
4134
|
+
*
|
|
4135
|
+
* **Fail-safe by construction.** It acts only on an explicit terminal status
|
|
4136
|
+
* from a closed set. Every other answer — `paused` (the normal state of a run
|
|
4137
|
+
* waiting on its approval), `running`, an unrecognised status, `null` (unknown
|
|
4138
|
+
* run, evicted log, no durable store), a `getRun` that throws, or no automation
|
|
4139
|
+
* engine at all — is read as "still alive" and left strictly alone. The failure
|
|
4140
|
+
* mode is therefore "a dead run's lock survives until an admin recalls it"
|
|
4141
|
+
* (today's behaviour, #3424), never "a live approval is destroyed".
|
|
4142
|
+
*
|
|
4143
|
+
* `recalled` is the finalisation because it is the platform's existing terminal
|
|
4144
|
+
* state for *a live request that ended without a decision*; the audit row names
|
|
4145
|
+
* the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
|
|
4146
|
+
* release is never mistaken for a submitter's withdrawal.
|
|
4147
|
+
*/
|
|
4148
|
+
async releaseDeadRunRequests() {
|
|
4149
|
+
if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
|
|
4150
|
+
let rows = [];
|
|
4151
|
+
try {
|
|
4152
|
+
rows = await this.engine.find("sys_approval_request", {
|
|
4153
|
+
where: { status: "pending" },
|
|
4154
|
+
limit: 500,
|
|
4155
|
+
context: SYSTEM_CTX2
|
|
4156
|
+
}) ?? [];
|
|
4157
|
+
} catch (err) {
|
|
4158
|
+
this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
|
|
4159
|
+
error: err?.message ?? String(err)
|
|
4160
|
+
});
|
|
4161
|
+
return { scanned: 0, released: 0 };
|
|
4162
|
+
}
|
|
4163
|
+
let released = 0;
|
|
4164
|
+
for (const raw of rows) {
|
|
4165
|
+
try {
|
|
4166
|
+
const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
|
|
4167
|
+
if (!runId) continue;
|
|
4168
|
+
let status;
|
|
4169
|
+
try {
|
|
4170
|
+
const run = await this.automation.getRun(runId);
|
|
4171
|
+
status = typeof run?.status === "string" ? run.status : void 0;
|
|
4172
|
+
} catch (err) {
|
|
4173
|
+
this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
|
|
4174
|
+
request: raw?.id,
|
|
4175
|
+
run: runId,
|
|
4176
|
+
error: err?.message ?? String(err)
|
|
4177
|
+
});
|
|
4178
|
+
continue;
|
|
4179
|
+
}
|
|
4180
|
+
if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
|
|
4181
|
+
await this.abandonForDeadRun(raw, runId, status);
|
|
4182
|
+
released++;
|
|
4183
|
+
} catch (err) {
|
|
4184
|
+
this.logger?.warn?.("[approvals] dead-run release failed for request", {
|
|
4185
|
+
request: raw?.id,
|
|
4186
|
+
error: err?.message ?? String(err)
|
|
4187
|
+
});
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
if (released > 0) {
|
|
4191
|
+
this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
|
|
4192
|
+
}
|
|
4193
|
+
return { scanned: rows.length, released };
|
|
4194
|
+
}
|
|
4195
|
+
/**
|
|
4196
|
+
* Finalise one pending request whose owning run is terminal. Mirrors the
|
|
4197
|
+
* shape of {@link recall} — audit row first (so a crash mid-release leaves a
|
|
4198
|
+
* trace of the intent), then the status transition, approver-index sync and
|
|
4199
|
+
* the optional status-field mirror. No resume/cancel of the run: it is already
|
|
4200
|
+
* terminal, which is precisely why we are here.
|
|
4201
|
+
*/
|
|
4202
|
+
async abandonForDeadRun(raw, runId, runStatus) {
|
|
4203
|
+
const org = raw.organization_id ?? null;
|
|
4204
|
+
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
4205
|
+
const now = this.clock.now().toISOString();
|
|
4206
|
+
await this.engine.insert("sys_approval_action", {
|
|
4207
|
+
id: uid("aact"),
|
|
4208
|
+
request_id: raw.id,
|
|
4209
|
+
organization_id: org,
|
|
4210
|
+
step_name: nodeId,
|
|
4211
|
+
step_index: 0,
|
|
4212
|
+
action: "recall",
|
|
4213
|
+
actor_id: DEAD_RUN_ACTOR_ID,
|
|
4214
|
+
comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
|
|
4215
|
+
created_at: now
|
|
4216
|
+
}, { context: SYSTEM_CTX2 });
|
|
4217
|
+
await this.engine.update("sys_approval_request", {
|
|
4218
|
+
id: raw.id,
|
|
4219
|
+
status: "recalled",
|
|
4220
|
+
pending_approvers: null,
|
|
4221
|
+
completed_at: now,
|
|
4222
|
+
updated_at: now
|
|
4223
|
+
}, { context: SYSTEM_CTX2 });
|
|
4224
|
+
await this.syncApproverIndex(raw.id, [], org, now);
|
|
4225
|
+
const config = parseJson(
|
|
4226
|
+
raw.node_config_json,
|
|
4227
|
+
{ approvers: [], behavior: "first_response" }
|
|
4228
|
+
);
|
|
4229
|
+
if (config.approvalStatusField) {
|
|
4230
|
+
await this.mirrorStatusField(
|
|
4231
|
+
raw.object_name,
|
|
4232
|
+
raw.record_id,
|
|
4233
|
+
config.approvalStatusField,
|
|
4234
|
+
"recalled",
|
|
4235
|
+
null
|
|
4236
|
+
);
|
|
4237
|
+
}
|
|
4238
|
+
this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
|
|
4239
|
+
request: raw.id,
|
|
4240
|
+
run: runId,
|
|
4241
|
+
runStatus,
|
|
4242
|
+
object: raw.object_name,
|
|
4243
|
+
record: raw.record_id
|
|
4244
|
+
});
|
|
4245
|
+
}
|
|
2912
4246
|
/** Execute the configured escalation action for one overdue request. */
|
|
2913
4247
|
async escalateRequest(raw, esc2) {
|
|
2914
4248
|
const action = esc2.action ?? "notify";
|
|
@@ -2934,13 +4268,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2934
4268
|
actor_id: SLA_ACTOR_ID,
|
|
2935
4269
|
comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
|
|
2936
4270
|
created_at: now
|
|
2937
|
-
}, { context:
|
|
4271
|
+
}, { context: SYSTEM_CTX2 });
|
|
2938
4272
|
if (action === "reassign" && escalatees.length) {
|
|
2939
4273
|
await this.engine.update("sys_approval_request", {
|
|
2940
4274
|
id: raw.id,
|
|
2941
4275
|
pending_approvers: escalatees.join(","),
|
|
2942
4276
|
updated_at: now
|
|
2943
|
-
}, { context:
|
|
4277
|
+
}, { context: SYSTEM_CTX2 });
|
|
2944
4278
|
await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
|
|
2945
4279
|
await this.notify({
|
|
2946
4280
|
topic: "approval.escalated",
|
|
@@ -2958,7 +4292,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2958
4292
|
decision: action === "auto_approve" ? "approve" : "reject",
|
|
2959
4293
|
actorId: SLA_ACTOR_ID,
|
|
2960
4294
|
comment: "SLA escalation"
|
|
2961
|
-
},
|
|
4295
|
+
}, SYSTEM_CTX2);
|
|
2962
4296
|
} else {
|
|
2963
4297
|
await this.notify({
|
|
2964
4298
|
topic: "approval.sla_breached",
|
|
@@ -3026,7 +4360,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3026
4360
|
where: { id: { $in: targets } },
|
|
3027
4361
|
fields: ["id", "name", "email"],
|
|
3028
4362
|
limit: targets.length,
|
|
3029
|
-
context:
|
|
4363
|
+
context: SYSTEM_CTX2
|
|
3030
4364
|
});
|
|
3031
4365
|
for (const u of users ?? []) {
|
|
3032
4366
|
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
@@ -3040,7 +4374,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3040
4374
|
where: { email: { $in: unresolvedEmails } },
|
|
3041
4375
|
fields: ["email", "name"],
|
|
3042
4376
|
limit: unresolvedEmails.length,
|
|
3043
|
-
context:
|
|
4377
|
+
context: SYSTEM_CTX2
|
|
3044
4378
|
});
|
|
3045
4379
|
for (const u of users ?? []) {
|
|
3046
4380
|
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
@@ -3066,11 +4400,32 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3066
4400
|
return [];
|
|
3067
4401
|
}
|
|
3068
4402
|
}
|
|
4403
|
+
/**
|
|
4404
|
+
* Field key → display label for an object's schema. Lets the inbox summary
|
|
4405
|
+
* show a human field name ("考核状态") instead of a title-cased machine key
|
|
4406
|
+
* ("Assessment Status"). For a single-locale project the schema label already
|
|
4407
|
+
* IS the localized string; symmetric with `resolveDisplayField`/lookup
|
|
4408
|
+
* resolution that power `payload_display`.
|
|
4409
|
+
*/
|
|
4410
|
+
resolveFieldLabels(object) {
|
|
4411
|
+
try {
|
|
4412
|
+
const schema = this.engine.getSchema?.(object);
|
|
4413
|
+
const fields = schema?.fields ?? {};
|
|
4414
|
+
const out = {};
|
|
4415
|
+
for (const [key, f] of Object.entries(fields)) {
|
|
4416
|
+
if (f?.label) out[key] = String(f.label);
|
|
4417
|
+
}
|
|
4418
|
+
return out;
|
|
4419
|
+
} catch {
|
|
4420
|
+
return {};
|
|
4421
|
+
}
|
|
4422
|
+
}
|
|
3069
4423
|
/**
|
|
3070
4424
|
* Attach inbox display fields to rows so clients never render a raw
|
|
3071
4425
|
* identifier: `record_title`, `submitter_name`, `object_label`,
|
|
3072
|
-
* `pending_approver_names` (user-id approvers),
|
|
3073
|
-
* (lookup foreign keys in the snapshot → referenced record titles)
|
|
4426
|
+
* `pending_approver_names` (user-id approvers), `payload_display`
|
|
4427
|
+
* (lookup foreign keys in the snapshot → referenced record titles), and
|
|
4428
|
+
* `payload_labels` (snapshot field keys → the target object's field labels).
|
|
3074
4429
|
* Batched: one query per distinct object (target + referenced) plus one
|
|
3075
4430
|
* `sys_user` lookup. Best-effort — a deleted record falls back to the
|
|
3076
4431
|
* payload snapshot, and any failure leaves the field unset rather than
|
|
@@ -3102,7 +4457,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3102
4457
|
const recs = await this.engine.find(object, {
|
|
3103
4458
|
where: { id: { $in: ids } },
|
|
3104
4459
|
limit: ids.length,
|
|
3105
|
-
context:
|
|
4460
|
+
context: SYSTEM_CTX2
|
|
3106
4461
|
});
|
|
3107
4462
|
for (const rec of recs ?? []) {
|
|
3108
4463
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3112,9 +4467,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3112
4467
|
}
|
|
3113
4468
|
}
|
|
3114
4469
|
const lookupFieldsByObject = /* @__PURE__ */ new Map();
|
|
4470
|
+
const fieldLabelsByObject = /* @__PURE__ */ new Map();
|
|
3115
4471
|
for (const object of byObject.keys()) {
|
|
3116
4472
|
const lookups = this.resolveLookupFields(object);
|
|
3117
4473
|
if (lookups.length) lookupFieldsByObject.set(object, lookups);
|
|
4474
|
+
const labels = this.resolveFieldLabels(object);
|
|
4475
|
+
if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
|
|
3118
4476
|
}
|
|
3119
4477
|
const refIds = /* @__PURE__ */ new Map();
|
|
3120
4478
|
for (const r of rows) {
|
|
@@ -3140,7 +4498,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3140
4498
|
const recs = await this.engine.find(object, {
|
|
3141
4499
|
where: { id: { $in: ids } },
|
|
3142
4500
|
limit: ids.length,
|
|
3143
|
-
context:
|
|
4501
|
+
context: SYSTEM_CTX2
|
|
3144
4502
|
});
|
|
3145
4503
|
for (const rec of recs ?? []) {
|
|
3146
4504
|
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
@@ -3181,6 +4539,15 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3181
4539
|
}
|
|
3182
4540
|
if (Object.keys(display).length) r.payload_display = display;
|
|
3183
4541
|
}
|
|
4542
|
+
const fieldLabels = fieldLabelsByObject.get(r.object_name);
|
|
4543
|
+
if (fieldLabels && r.payload && typeof r.payload === "object") {
|
|
4544
|
+
const labels = {};
|
|
4545
|
+
for (const key of Object.keys(r.payload)) {
|
|
4546
|
+
const l = fieldLabels[key];
|
|
4547
|
+
if (l) labels[key] = l;
|
|
4548
|
+
}
|
|
4549
|
+
if (Object.keys(labels).length) r.payload_labels = labels;
|
|
4550
|
+
}
|
|
3184
4551
|
}
|
|
3185
4552
|
}
|
|
3186
4553
|
// ── Pending-approver index (issue #1745) ─────────────────────
|
|
@@ -3196,12 +4563,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3196
4563
|
const existing = await this.engine.find("sys_approval_approver", {
|
|
3197
4564
|
where: { request_id: requestId },
|
|
3198
4565
|
limit: 500,
|
|
3199
|
-
context:
|
|
4566
|
+
context: SYSTEM_CTX2
|
|
3200
4567
|
});
|
|
3201
4568
|
const rows = Array.isArray(existing) ? existing : [];
|
|
3202
4569
|
for (const row of rows) {
|
|
3203
4570
|
if (desired.has(String(row.approver))) desired.delete(String(row.approver));
|
|
3204
|
-
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4571
|
+
else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3205
4572
|
}
|
|
3206
4573
|
for (const approver of desired) {
|
|
3207
4574
|
await this.engine.insert("sys_approval_approver", {
|
|
@@ -3210,7 +4577,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3210
4577
|
approver,
|
|
3211
4578
|
organization_id: org,
|
|
3212
4579
|
created_at: now
|
|
3213
|
-
}, { context:
|
|
4580
|
+
}, { context: SYSTEM_CTX2 });
|
|
3214
4581
|
}
|
|
3215
4582
|
}
|
|
3216
4583
|
/**
|
|
@@ -3222,35 +4589,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3222
4589
|
async rebuildApproverIndex() {
|
|
3223
4590
|
const desired = /* @__PURE__ */ new Map();
|
|
3224
4591
|
const PAGE = 500;
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
4592
|
+
const requests = (0, import_types.keysetWalk)(
|
|
4593
|
+
(q) => this.engine.find("sys_approval_request", {
|
|
4594
|
+
...q,
|
|
3228
4595
|
fields: ["id", "pending_approvers", "organization_id"],
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
4596
|
+
context: SYSTEM_CTX2
|
|
4597
|
+
}),
|
|
4598
|
+
{ where: { status: "pending" }, pageSize: PAGE }
|
|
4599
|
+
);
|
|
4600
|
+
for await (const rows of requests.pages()) {
|
|
3234
4601
|
for (const r of rows) {
|
|
3235
4602
|
desired.set(String(r.id), {
|
|
3236
4603
|
approvers: new Set(csvSplit(r.pending_approvers)),
|
|
3237
4604
|
org: r.organization_id ?? null
|
|
3238
4605
|
});
|
|
3239
4606
|
}
|
|
3240
|
-
if (rows.length < PAGE) break;
|
|
3241
4607
|
}
|
|
3242
4608
|
const indexRows = [];
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
context: SYSTEM_CTX
|
|
3249
|
-
});
|
|
3250
|
-
const rows = Array.isArray(batch) ? batch : [];
|
|
3251
|
-
indexRows.push(...rows);
|
|
3252
|
-
if (rows.length < PAGE) break;
|
|
3253
|
-
}
|
|
4609
|
+
const index = (0, import_types.keysetWalk)(
|
|
4610
|
+
(q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
|
|
4611
|
+
{ pageSize: PAGE }
|
|
4612
|
+
);
|
|
4613
|
+
for await (const rows of index.pages()) indexRows.push(...rows);
|
|
3254
4614
|
let inserted = 0;
|
|
3255
4615
|
let deleted = 0;
|
|
3256
4616
|
const seen = /* @__PURE__ */ new Map();
|
|
@@ -3259,7 +4619,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3259
4619
|
const want = desired.get(reqId);
|
|
3260
4620
|
const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
|
|
3261
4621
|
if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
|
|
3262
|
-
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context:
|
|
4622
|
+
await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
|
|
3263
4623
|
deleted++;
|
|
3264
4624
|
continue;
|
|
3265
4625
|
}
|
|
@@ -3276,7 +4636,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3276
4636
|
approver,
|
|
3277
4637
|
organization_id: want.org,
|
|
3278
4638
|
created_at: now
|
|
3279
|
-
}, { context:
|
|
4639
|
+
}, { context: SYSTEM_CTX2 });
|
|
3280
4640
|
inserted++;
|
|
3281
4641
|
}
|
|
3282
4642
|
}
|
|
@@ -3326,7 +4686,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3326
4686
|
where,
|
|
3327
4687
|
fields: ["request_id"],
|
|
3328
4688
|
limit: _ApprovalService.APPROVER_INDEX_CAP,
|
|
3329
|
-
context:
|
|
4689
|
+
context: SYSTEM_CTX2
|
|
3330
4690
|
});
|
|
3331
4691
|
const list = Array.isArray(rows) ? rows : [];
|
|
3332
4692
|
if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
|
|
@@ -3337,6 +4697,88 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3337
4697
|
}
|
|
3338
4698
|
return [...new Set(list.map((r) => String(r.request_id)))];
|
|
3339
4699
|
}
|
|
4700
|
+
/**
|
|
4701
|
+
* The request ids this caller is a PARTICIPANT of, or `null` for a caller
|
|
4702
|
+
* who may see everything in scope (#3590).
|
|
4703
|
+
*
|
|
4704
|
+
* These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
|
|
4705
|
+
* approver-visibility rule spans several identity forms that RLS cannot model
|
|
4706
|
+
* cleanly, which is why it has to be expressed here. Until now only the
|
|
4707
|
+
* TENANT half of that rule was applied, so any authenticated user could read
|
|
4708
|
+
* any request in their tenant (and, once attachments derived their access
|
|
4709
|
+
* from the request, its files too). This adds the participant half.
|
|
4710
|
+
*
|
|
4711
|
+
* A participant is the submitter, a current approver, or someone who has
|
|
4712
|
+
* already acted on the request (a past approver whose slot has moved on, a
|
|
4713
|
+
* commenter). Admins with override authority keep the unrestricted view the
|
|
4714
|
+
* "all requests" console surface depends on.
|
|
4715
|
+
*
|
|
4716
|
+
* Keying on the concrete user id is sufficient rather than an approximation:
|
|
4717
|
+
* position/team/manager/field approvers are resolved to concrete user ids at
|
|
4718
|
+
* open time, and the `type:value` literal is only the fallback for a spec
|
|
4719
|
+
* that resolved to NOBODY — a slot no one can act on either way (`can_act`
|
|
4720
|
+
* is a plain membership test over the resolved ids). So this cannot hide a
|
|
4721
|
+
* request from someone who could actually act on it.
|
|
4722
|
+
*/
|
|
4723
|
+
async visibleRequestIds(context, tenantOrg) {
|
|
4724
|
+
if (this.isOverrideActor(context, tenantOrg)) return null;
|
|
4725
|
+
const uid2 = context?.userId != null ? String(context.userId) : "";
|
|
4726
|
+
if (!uid2) return /* @__PURE__ */ new Set();
|
|
4727
|
+
const ids = /* @__PURE__ */ new Set();
|
|
4728
|
+
const cap = _ApprovalService.APPROVER_INDEX_CAP;
|
|
4729
|
+
const add = (rows, key) => {
|
|
4730
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
4731
|
+
for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
|
|
4732
|
+
if (list.length >= cap) {
|
|
4733
|
+
this.logger?.warn?.(
|
|
4734
|
+
"[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
|
|
4735
|
+
{ cap, key }
|
|
4736
|
+
);
|
|
4737
|
+
}
|
|
4738
|
+
};
|
|
4739
|
+
try {
|
|
4740
|
+
for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
|
|
4741
|
+
const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
|
|
4742
|
+
add(
|
|
4743
|
+
await this.engine.find("sys_approval_request", {
|
|
4744
|
+
where: { submitter_id: uid2, ...orgWhere },
|
|
4745
|
+
fields: ["id"],
|
|
4746
|
+
limit: cap,
|
|
4747
|
+
context: SYSTEM_CTX2
|
|
4748
|
+
}),
|
|
4749
|
+
"id"
|
|
4750
|
+
);
|
|
4751
|
+
add(
|
|
4752
|
+
await this.engine.find("sys_approval_action", {
|
|
4753
|
+
where: { actor_id: uid2 },
|
|
4754
|
+
fields: ["request_id"],
|
|
4755
|
+
limit: cap,
|
|
4756
|
+
context: SYSTEM_CTX2
|
|
4757
|
+
}),
|
|
4758
|
+
"request_id"
|
|
4759
|
+
);
|
|
4760
|
+
} catch (err) {
|
|
4761
|
+
this.logger?.warn?.("[approvals] participant-visibility probe failed", {
|
|
4762
|
+
error: err instanceof Error ? err.message : String(err)
|
|
4763
|
+
});
|
|
4764
|
+
}
|
|
4765
|
+
return ids;
|
|
4766
|
+
}
|
|
4767
|
+
/** Intersect an existing `where.id` constraint with the participant set. */
|
|
4768
|
+
applyVisibility(where, visible) {
|
|
4769
|
+
if (!visible) return true;
|
|
4770
|
+
if (visible.size === 0) return false;
|
|
4771
|
+
let allowed = [...visible];
|
|
4772
|
+
const current = where.id;
|
|
4773
|
+
if (typeof current === "string") allowed = allowed.filter((x) => x === current);
|
|
4774
|
+
else if (current && typeof current === "object" && Array.isArray(current.$in)) {
|
|
4775
|
+
const set = new Set(current.$in.map((v) => String(v)));
|
|
4776
|
+
allowed = allowed.filter((x) => set.has(x));
|
|
4777
|
+
}
|
|
4778
|
+
if (allowed.length === 0) return false;
|
|
4779
|
+
where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
|
|
4780
|
+
return true;
|
|
4781
|
+
}
|
|
3340
4782
|
async listRequests(filter, context) {
|
|
3341
4783
|
const { where, tenantOrg } = this.buildRequestWhere(filter, context);
|
|
3342
4784
|
const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
|
|
@@ -3345,10 +4787,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3345
4787
|
if (ids.length === 0) return [];
|
|
3346
4788
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3347
4789
|
}
|
|
4790
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
|
|
3348
4791
|
const findOpts = {
|
|
3349
4792
|
where,
|
|
3350
4793
|
orderBy: [{ field: "created_at", order: "desc" }],
|
|
3351
|
-
context:
|
|
4794
|
+
context: SYSTEM_CTX2
|
|
3352
4795
|
};
|
|
3353
4796
|
if (filter?.limit != null || filter?.offset != null) {
|
|
3354
4797
|
findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
|
|
@@ -3370,10 +4813,11 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3370
4813
|
if (ids.length === 0) return 0;
|
|
3371
4814
|
where.id = ids.length === 1 ? ids[0] : { $in: ids };
|
|
3372
4815
|
}
|
|
4816
|
+
if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
|
|
3373
4817
|
const countFn = this.engine.count;
|
|
3374
4818
|
if (typeof countFn === "function") {
|
|
3375
4819
|
try {
|
|
3376
|
-
const n = await countFn.call(this.engine, "sys_approval_request", { where, context:
|
|
4820
|
+
const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
|
|
3377
4821
|
if (typeof n === "number") return n;
|
|
3378
4822
|
} catch {
|
|
3379
4823
|
}
|
|
@@ -3382,11 +4826,28 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3382
4826
|
where,
|
|
3383
4827
|
fields: ["id"],
|
|
3384
4828
|
limit: ids ? Math.max(500, ids.length) : 500,
|
|
3385
|
-
context:
|
|
4829
|
+
context: SYSTEM_CTX2
|
|
3386
4830
|
});
|
|
3387
4831
|
return Array.isArray(rows) ? rows.length : 0;
|
|
3388
4832
|
}
|
|
4833
|
+
/**
|
|
4834
|
+
* Read the request a write path just changed, to echo back as its result.
|
|
4835
|
+
*
|
|
4836
|
+
* NOT participant-gated (#3590), deliberately: the operation authorized
|
|
4837
|
+
* itself by its own rule before writing, so re-asking "may you see this?"
|
|
4838
|
+
* for the echo answers a question that has already been settled — and would
|
|
4839
|
+
* answer it WRONG for a caller context that carries no `userId` (a
|
|
4840
|
+
* flow-driven resume, a service-to-service call), turning a successful write
|
|
4841
|
+
* into a `null` result. Gating belongs on the read API, not on an
|
|
4842
|
+
* operation's own return value.
|
|
4843
|
+
*/
|
|
4844
|
+
async readBackRequest(requestId, context) {
|
|
4845
|
+
return this.loadRequest(requestId, context, false);
|
|
4846
|
+
}
|
|
3389
4847
|
async getRequest(requestId, context) {
|
|
4848
|
+
return this.loadRequest(requestId, context, true);
|
|
4849
|
+
}
|
|
4850
|
+
async loadRequest(requestId, context, enforceVisibility) {
|
|
3390
4851
|
if (!requestId) return null;
|
|
3391
4852
|
const where = { id: requestId };
|
|
3392
4853
|
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
@@ -3394,9 +4855,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3394
4855
|
const rows = await this.engine.find("sys_approval_request", {
|
|
3395
4856
|
where,
|
|
3396
4857
|
limit: 1,
|
|
3397
|
-
context:
|
|
4858
|
+
context: SYSTEM_CTX2
|
|
3398
4859
|
});
|
|
3399
4860
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
4861
|
+
if (enforceVisibility) {
|
|
4862
|
+
const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
|
|
4863
|
+
if (visible && !visible.has(String(rows[0].id))) return null;
|
|
4864
|
+
}
|
|
3400
4865
|
const row = rowFromRequest(rows[0]);
|
|
3401
4866
|
await this.enrichRows([row]);
|
|
3402
4867
|
await this.attachFlowSteps(row);
|
|
@@ -3423,7 +4888,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3423
4888
|
const acts = await this.engine.find("sys_approval_action", {
|
|
3424
4889
|
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3425
4890
|
limit: 1e3,
|
|
3426
|
-
context:
|
|
4891
|
+
context: SYSTEM_CTX2
|
|
3427
4892
|
});
|
|
3428
4893
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3429
4894
|
const snapshot = cfg?.__approverGroups;
|
|
@@ -3444,6 +4909,14 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3444
4909
|
});
|
|
3445
4910
|
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3446
4911
|
progress.need = progress.groups.length;
|
|
4912
|
+
const pendingGroups = {};
|
|
4913
|
+
for (const a of row.pending_approvers ?? []) {
|
|
4914
|
+
const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
|
|
4915
|
+
if (named.length) pendingGroups[a] = named;
|
|
4916
|
+
}
|
|
4917
|
+
if (Object.keys(pendingGroups).length) {
|
|
4918
|
+
row.pending_approver_groups = pendingGroups;
|
|
4919
|
+
}
|
|
3447
4920
|
}
|
|
3448
4921
|
row.decision_progress = progress;
|
|
3449
4922
|
} catch {
|
|
@@ -3455,8 +4928,13 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3455
4928
|
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
3456
4929
|
* still `pending` (position/team/manager approvers are already resolved to
|
|
3457
4930
|
* concrete user ids at open time, so a plain membership test is faithful).
|
|
3458
|
-
* `is_submitter` is a straight owner check.
|
|
3459
|
-
*
|
|
4931
|
+
* `is_submitter` is a straight owner check. `can_override` (#3424) is true for
|
|
4932
|
+
* a platform/tenant admin on a PENDING request — the recovery path for an
|
|
4933
|
+
* approval routed to an unstaffed position or to approvers who have all left;
|
|
4934
|
+
* clients OR it into the decision actions' `visible` gate so an admin can act
|
|
4935
|
+
* even when they hold no slot. System/tokenless contexts get a both-false
|
|
4936
|
+
* `can_act`/`is_submitter` block (system gets `can_override` too — it may act
|
|
4937
|
+
* on anything). Cheap + synchronous — safe on list reads.
|
|
3460
4938
|
*/
|
|
3461
4939
|
attachViewers(rows, context) {
|
|
3462
4940
|
const uid2 = context?.userId != null ? String(context.userId) : null;
|
|
@@ -3464,7 +4942,8 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3464
4942
|
const pending = row.pending_approvers ?? [];
|
|
3465
4943
|
row.viewer = {
|
|
3466
4944
|
can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
|
|
3467
|
-
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
|
|
4945
|
+
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
|
|
4946
|
+
can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
|
|
3468
4947
|
};
|
|
3469
4948
|
}
|
|
3470
4949
|
}
|
|
@@ -3511,26 +4990,60 @@ var _ApprovalService = class _ApprovalService {
|
|
|
3511
4990
|
where: { request_id: requestId },
|
|
3512
4991
|
limit: 500,
|
|
3513
4992
|
orderBy: [{ field: "created_at", order: "asc" }],
|
|
3514
|
-
context:
|
|
4993
|
+
context: SYSTEM_CTX2
|
|
3515
4994
|
});
|
|
3516
4995
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
3517
4996
|
const names = await this.resolveUserNames(
|
|
3518
|
-
actions.
|
|
4997
|
+
actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
|
|
3519
4998
|
);
|
|
3520
4999
|
for (const a of actions) {
|
|
3521
5000
|
const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
|
|
3522
5001
|
if (n) a.actor_name = n;
|
|
5002
|
+
const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
|
|
5003
|
+
if (fromName) a.reassign_from_name = fromName;
|
|
5004
|
+
const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
|
|
5005
|
+
if (toName) a.reassign_to_name = toName;
|
|
3523
5006
|
}
|
|
3524
5007
|
return actions;
|
|
3525
5008
|
}
|
|
5009
|
+
/**
|
|
5010
|
+
* `IFileAccessDelegate` — may this caller download a decision attachment?
|
|
5011
|
+
* (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
|
|
5012
|
+
*
|
|
5013
|
+
* A file referenced by `sys_approval_action.attachments` is owned by that
|
|
5014
|
+
* audit row, so the storage service would otherwise authorize the download by
|
|
5015
|
+
* testing whether the caller can READ the row. It cannot: `sys_approval_action`
|
|
5016
|
+
* is deliberately closed to ordinary approver positions, so that test denies
|
|
5017
|
+
* the very approver the attachment was filed for.
|
|
5018
|
+
*
|
|
5019
|
+
* The rule that actually governs seeing a decision is the one `listActions`
|
|
5020
|
+
* applies — can the caller see the PARENT REQUEST? — so this reuses it
|
|
5021
|
+
* exactly, rather than inventing a second, looser rule for the bytes. Fails
|
|
5022
|
+
* closed on any error.
|
|
5023
|
+
*/
|
|
5024
|
+
async authorizeFileRead(actionId, context) {
|
|
5025
|
+
if (!actionId) return false;
|
|
5026
|
+
try {
|
|
5027
|
+
const rows = await this.engine.find("sys_approval_action", {
|
|
5028
|
+
where: { id: actionId },
|
|
5029
|
+
limit: 1,
|
|
5030
|
+
context: SYSTEM_CTX2
|
|
5031
|
+
});
|
|
5032
|
+
const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
|
|
5033
|
+
if (!requestId) return false;
|
|
5034
|
+
return !!await this.getRequest(String(requestId), context);
|
|
5035
|
+
} catch {
|
|
5036
|
+
return false;
|
|
5037
|
+
}
|
|
5038
|
+
}
|
|
3526
5039
|
};
|
|
3527
5040
|
/** Window the approver-index probe — pending queues live far below this. */
|
|
3528
5041
|
_ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
3529
5042
|
var ApprovalService = _ApprovalService;
|
|
3530
5043
|
|
|
3531
5044
|
// src/sys-approval-token.object.ts
|
|
3532
|
-
var
|
|
3533
|
-
var SysApprovalToken =
|
|
5045
|
+
var import_data6 = require("@objectstack/spec/data");
|
|
5046
|
+
var SysApprovalToken = import_data6.ObjectSchema.create({
|
|
3534
5047
|
name: "sys_approval_token",
|
|
3535
5048
|
label: "Approval Action Token",
|
|
3536
5049
|
pluralLabel: "Approval Action Tokens",
|
|
@@ -3542,13 +5055,13 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
|
3542
5055
|
nameField: "id",
|
|
3543
5056
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
3544
5057
|
fields: {
|
|
3545
|
-
id:
|
|
3546
|
-
organization_id:
|
|
5058
|
+
id: import_data6.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
|
|
5059
|
+
organization_id: import_data6.Field.lookup("sys_organization", {
|
|
3547
5060
|
label: "Organization",
|
|
3548
5061
|
required: false,
|
|
3549
5062
|
group: "System"
|
|
3550
5063
|
}),
|
|
3551
|
-
token_hash:
|
|
5064
|
+
token_hash: import_data6.Field.text({
|
|
3552
5065
|
label: "Token Hash",
|
|
3553
5066
|
required: true,
|
|
3554
5067
|
maxLength: 100,
|
|
@@ -3556,20 +5069,20 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
|
3556
5069
|
description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
|
|
3557
5070
|
group: "Token"
|
|
3558
5071
|
}),
|
|
3559
|
-
request_id:
|
|
5072
|
+
request_id: import_data6.Field.text({
|
|
3560
5073
|
label: "Request",
|
|
3561
5074
|
required: true,
|
|
3562
5075
|
maxLength: 100,
|
|
3563
5076
|
readonly: true,
|
|
3564
5077
|
group: "Token"
|
|
3565
5078
|
}),
|
|
3566
|
-
action:
|
|
5079
|
+
action: import_data6.Field.select(["approve", "reject"], {
|
|
3567
5080
|
label: "Action",
|
|
3568
5081
|
required: true,
|
|
3569
5082
|
readonly: true,
|
|
3570
5083
|
group: "Token"
|
|
3571
5084
|
}),
|
|
3572
|
-
approver_id:
|
|
5085
|
+
approver_id: import_data6.Field.text({
|
|
3573
5086
|
label: "Approver",
|
|
3574
5087
|
required: true,
|
|
3575
5088
|
maxLength: 200,
|
|
@@ -3577,18 +5090,18 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
|
3577
5090
|
description: "Identity the token is bound to; the decision is audited as this approver",
|
|
3578
5091
|
group: "Token"
|
|
3579
5092
|
}),
|
|
3580
|
-
expires_at:
|
|
5093
|
+
expires_at: import_data6.Field.datetime({
|
|
3581
5094
|
label: "Expires At",
|
|
3582
5095
|
required: true,
|
|
3583
5096
|
readonly: true,
|
|
3584
5097
|
group: "Lifecycle"
|
|
3585
5098
|
}),
|
|
3586
|
-
consumed_at:
|
|
5099
|
+
consumed_at: import_data6.Field.datetime({
|
|
3587
5100
|
label: "Consumed At",
|
|
3588
5101
|
required: false,
|
|
3589
5102
|
group: "Lifecycle"
|
|
3590
5103
|
}),
|
|
3591
|
-
created_at:
|
|
5104
|
+
created_at: import_data6.Field.datetime({
|
|
3592
5105
|
label: "Created At",
|
|
3593
5106
|
required: true,
|
|
3594
5107
|
defaultValue: "NOW()",
|
|
@@ -3717,6 +5230,8 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
3717
5230
|
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3718
5231
|
const pending = await pendingRequestFor(engine, object, id);
|
|
3719
5232
|
if (!pending) return;
|
|
5233
|
+
const writerRun = ctx?.provenance?.flowRunId;
|
|
5234
|
+
if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
|
|
3720
5235
|
const config = parseJson2(pending.node_config_json, {});
|
|
3721
5236
|
if (config?.lockRecord === false) return;
|
|
3722
5237
|
const mirror = config?.approvalStatusField;
|
|
@@ -3768,7 +5283,22 @@ function unbindAllHooks(engine) {
|
|
|
3768
5283
|
|
|
3769
5284
|
// src/approval-node.ts
|
|
3770
5285
|
var import_automation2 = require("@objectstack/spec/automation");
|
|
3771
|
-
var
|
|
5286
|
+
var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
|
|
5287
|
+
function nestVariables(variables) {
|
|
5288
|
+
const vars = {};
|
|
5289
|
+
for (const [key, value] of variables) {
|
|
5290
|
+
const segs = key.split(".");
|
|
5291
|
+
let cursor = vars;
|
|
5292
|
+
for (let i = 0; i < segs.length - 1; i++) {
|
|
5293
|
+
if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
|
|
5294
|
+
cursor[segs[i]] = {};
|
|
5295
|
+
}
|
|
5296
|
+
cursor = cursor[segs[i]];
|
|
5297
|
+
}
|
|
5298
|
+
cursor[segs[segs.length - 1]] = value;
|
|
5299
|
+
}
|
|
5300
|
+
return vars;
|
|
5301
|
+
}
|
|
3772
5302
|
function registerApprovalNode(automation, service, logger) {
|
|
3773
5303
|
automation.registerNodeExecutor({
|
|
3774
5304
|
type: import_automation2.APPROVAL_NODE_TYPE,
|
|
@@ -3784,6 +5314,15 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3784
5314
|
// Human decision: the run suspends here awaiting an external reply.
|
|
3785
5315
|
supportsPause: true,
|
|
3786
5316
|
isAsync: true,
|
|
5317
|
+
// #3801: this pause is NOT resumable through the generic run-resume
|
|
5318
|
+
// route. Continuing an approval is a side effect of a DECISION, and the
|
|
5319
|
+
// decision is the thing that must be authorized (the approver slate),
|
|
5320
|
+
// recorded (`sys_approval_action`) and mirrored (the status field) —
|
|
5321
|
+
// all of which lives in `ApprovalService.decide`. The engine now refuses
|
|
5322
|
+
// any resume of an approval suspension that does not carry the service's
|
|
5323
|
+
// in-process marker, so "decide via the approvals API, never a raw engine
|
|
5324
|
+
// resume" is enforced rather than merely documented.
|
|
5325
|
+
resumeAuthority: "service",
|
|
3787
5326
|
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
3788
5327
|
// Studio flow designer renders the Approval property form from the engine
|
|
3789
5328
|
// rather than a hardcoded client form — the engine owns the shape.
|
|
@@ -3817,13 +5356,28 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
3817
5356
|
nodeLabel: typeof node.label === "string" ? node.label : void 0,
|
|
3818
5357
|
submitterId: context?.userId ?? null,
|
|
3819
5358
|
record,
|
|
3820
|
-
organizationId: context?.organizationId ?? context?.tenantId ?? null
|
|
5359
|
+
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
5360
|
+
// #3447 P2: flow variables (nested, as CEL conditions see them) — the
|
|
5361
|
+
// `vars.*` root for `expression` approvers; `record` above doubles as
|
|
5362
|
+
// their `trigger.*` snapshot root.
|
|
5363
|
+
variables: nestVariables(variables)
|
|
3821
5364
|
}, {
|
|
3822
|
-
...
|
|
5365
|
+
...SYSTEM_CTX3,
|
|
3823
5366
|
userId: context?.userId,
|
|
3824
5367
|
organizationId: context?.organizationId,
|
|
3825
5368
|
tenantId: context?.tenantId
|
|
3826
5369
|
});
|
|
5370
|
+
if ("autoApproved" in request) {
|
|
5371
|
+
logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
|
|
5372
|
+
node: node.id,
|
|
5373
|
+
run: String(runId)
|
|
5374
|
+
});
|
|
5375
|
+
return {
|
|
5376
|
+
success: true,
|
|
5377
|
+
branchLabel: "approve",
|
|
5378
|
+
output: { decision: "approve", autoApproved: true }
|
|
5379
|
+
};
|
|
5380
|
+
}
|
|
3827
5381
|
logger?.info?.("[approvals] approval node suspended run", {
|
|
3828
5382
|
node: node.id,
|
|
3829
5383
|
request: request.id,
|
|
@@ -3909,7 +5463,21 @@ var ApprovalsServicePlugin = class {
|
|
|
3909
5463
|
this.service = new ApprovalService({
|
|
3910
5464
|
engine,
|
|
3911
5465
|
logger: ctx.logger,
|
|
3912
|
-
publicBaseUrl: this.options.publicBaseUrl
|
|
5466
|
+
publicBaseUrl: this.options.publicBaseUrl,
|
|
5467
|
+
// [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
|
|
5468
|
+
// capability. Read LAZILY (not captured at start) because the tenancy
|
|
5469
|
+
// service resolves its posture during its own start, which may not have
|
|
5470
|
+
// run yet; an unresolvable posture reads as "unknown" and the guard
|
|
5471
|
+
// stands down rather than refusing a legitimate flow on a minimal stack.
|
|
5472
|
+
tenancyPosture: () => {
|
|
5473
|
+
try {
|
|
5474
|
+
const tenancy = ctx.getService("tenancy");
|
|
5475
|
+
const posture = tenancy?.posture;
|
|
5476
|
+
return typeof posture === "string" && posture ? posture : void 0;
|
|
5477
|
+
} catch {
|
|
5478
|
+
return void 0;
|
|
5479
|
+
}
|
|
5480
|
+
}
|
|
3913
5481
|
});
|
|
3914
5482
|
if (!this.options.disableAutoHooks) {
|
|
3915
5483
|
try {
|
|
@@ -3935,12 +5503,23 @@ var ApprovalsServicePlugin = class {
|
|
|
3935
5503
|
if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
|
|
3936
5504
|
const svc = this.service;
|
|
3937
5505
|
const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
|
|
3938
|
-
|
|
3939
|
-
await
|
|
3940
|
-
|
|
5506
|
+
const sweep = async () => {
|
|
5507
|
+
const results = await Promise.allSettled([
|
|
5508
|
+
svc.runEscalations(),
|
|
5509
|
+
svc.releaseDeadRunRequests()
|
|
5510
|
+
]);
|
|
5511
|
+
for (const r of results) {
|
|
5512
|
+
if (r.status === "rejected") {
|
|
5513
|
+
ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
|
|
5514
|
+
error: r.reason?.message ?? String(r.reason)
|
|
5515
|
+
});
|
|
5516
|
+
}
|
|
5517
|
+
}
|
|
5518
|
+
};
|
|
5519
|
+
await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
|
|
3941
5520
|
this.escalationJobScheduled = true;
|
|
3942
|
-
void
|
|
3943
|
-
ctx.logger.warn?.("[approvals] boot
|
|
5521
|
+
void sweep().catch((err) => {
|
|
5522
|
+
ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
|
|
3944
5523
|
});
|
|
3945
5524
|
ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
|
|
3946
5525
|
} catch {
|