@objectstack/plugin-approvals 15.1.0 → 16.0.0-rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +115 -0
- package/dist/index.d.mts +2999 -2410
- package/dist/index.d.ts +2999 -2410
- package/dist/index.js +849 -94
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +850 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/scripts/i18n-extract.config.ts +2 -1
- package/src/approval-service.test.ts +437 -1
- package/src/approval-service.ts +403 -58
- package/src/approvals-plugin.ts +9 -3
- package/src/index.ts +1 -0
- package/src/lifecycle-hooks.ts +64 -0
- package/src/nav-contribution.test.ts +2 -0
- package/src/sys-approval-action.object.ts +17 -2
- package/src/sys-approval-approver.object.ts +6 -0
- package/src/sys-approval-delegation.object.ts +142 -0
- package/src/sys-approval-request.object.test.ts +94 -0
- package/src/sys-approval-request.object.ts +160 -0
- package/src/sys-approval-token.object.ts +6 -0
- package/src/translations/en.objects.generated.ts +49 -0
- package/src/translations/es-ES.objects.generated.ts +49 -0
- package/src/translations/ja-JP.objects.generated.ts +49 -0
- package/src/translations/zh-CN.objects.generated.ts +49 -0
package/dist/index.js
CHANGED
|
@@ -174,6 +174,55 @@ var init_en_objects_generated = __esm({
|
|
|
174
174
|
label: "All"
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
+
},
|
|
178
|
+
sys_approval_delegation: {
|
|
179
|
+
label: "Approval Delegation",
|
|
180
|
+
pluralLabel: "Approval Delegations",
|
|
181
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
182
|
+
fields: {
|
|
183
|
+
id: {
|
|
184
|
+
label: "Delegation ID"
|
|
185
|
+
},
|
|
186
|
+
delegator_id: {
|
|
187
|
+
label: "Delegator",
|
|
188
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
189
|
+
},
|
|
190
|
+
delegate_id: {
|
|
191
|
+
label: "Delegate",
|
|
192
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
193
|
+
},
|
|
194
|
+
valid_from: {
|
|
195
|
+
label: "Valid From",
|
|
196
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
197
|
+
},
|
|
198
|
+
valid_until: {
|
|
199
|
+
label: "Valid Until",
|
|
200
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
201
|
+
},
|
|
202
|
+
reason: {
|
|
203
|
+
label: "Reason",
|
|
204
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
205
|
+
},
|
|
206
|
+
organization_id: {
|
|
207
|
+
label: "Organization",
|
|
208
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
209
|
+
},
|
|
210
|
+
created_at: {
|
|
211
|
+
label: "Created At"
|
|
212
|
+
},
|
|
213
|
+
updated_at: {
|
|
214
|
+
label: "Updated At"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
_views: {
|
|
218
|
+
active: {
|
|
219
|
+
label: "Active",
|
|
220
|
+
emptyState: {
|
|
221
|
+
title: "No delegations",
|
|
222
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
177
226
|
}
|
|
178
227
|
};
|
|
179
228
|
}
|
|
@@ -328,6 +377,55 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
328
377
|
label: "\u5168\u90E8"
|
|
329
378
|
}
|
|
330
379
|
}
|
|
380
|
+
},
|
|
381
|
+
sys_approval_delegation: {
|
|
382
|
+
label: "\u5BA1\u6279\u59D4\u6D3E",
|
|
383
|
+
pluralLabel: "\u5BA1\u6279\u59D4\u6D3E",
|
|
384
|
+
description: "\u81EA\u52A9\u4E0D\u5728\u5C97\u89C4\u5219\uFF1A\u5728\u65F6\u95F4\u7A97\u5185\u628A\u8BE5\u7528\u6237\u7684\u5BA1\u6279\u4EBA\u69FD\u4F4D\u6539\u6D3E\u7ED9\u5019\u8865\u4EBA\uFF08#1322 M1\uFF09\u3002",
|
|
385
|
+
fields: {
|
|
386
|
+
id: {
|
|
387
|
+
label: "\u59D4\u6D3E ID"
|
|
388
|
+
},
|
|
389
|
+
delegator_id: {
|
|
390
|
+
label: "\u59D4\u6D3E\u4EBA",
|
|
391
|
+
help: "\u5373\u5C06\u4E0D\u5728\u5C97\u7684\u7528\u6237\uFF1B\u89C4\u5219\u751F\u6548\u671F\u95F4\uFF0C\u8DEF\u7531\u5230\u5176\u4E2A\u4EBA\u7684\u5BA1\u6279\u4EBA\u69FD\u4F4D\u5C06\u88AB\u6539\u6D3E\u3002"
|
|
392
|
+
},
|
|
393
|
+
delegate_id: {
|
|
394
|
+
label: "\u5019\u8865\u4EBA",
|
|
395
|
+
help: "\u89C4\u5219\u751F\u6548\u671F\u95F4\u63A5\u6536\u59D4\u6D3E\u4EBA\u5BA1\u6279\u7684\u5019\u8865\u4EBA\u3002\u4EE5\u5176\u672C\u4EBA\u8EAB\u4EFD\u884C\u4F7F\u3002"
|
|
396
|
+
},
|
|
397
|
+
valid_from: {
|
|
398
|
+
label: "\u751F\u6548\u65F6\u95F4",
|
|
399
|
+
help: "\u6B64\u523B\u4E4B\u524D\u89C4\u5219\u4E0D\u751F\u6548\u3002\u7559\u7A7A = \u7ACB\u5373\u751F\u6548\u3002\u5728\u89E3\u6790\u65F6\u901A\u8FC7 isGrantActive \u5F3A\u5236\uFF08ADR-0091 D2 \u5224\u5B9A\u5F0F\uFF09\u2014\u2014\u7EDD\u4E0D\u4F9D\u8D56\u540E\u53F0\u4EFB\u52A1\u3002"
|
|
400
|
+
},
|
|
401
|
+
valid_until: {
|
|
402
|
+
label: "\u5931\u6548\u65F6\u95F4",
|
|
403
|
+
help: "\u6B64\u523B\uFF08\u542B\uFF09\u53CA\u4E4B\u540E\u89C4\u5219\u4E0D\u751F\u6548\uFF08\u534A\u5F00\u533A\u95F4 [from, until)\uFF0CUTC\uFF09\u3002\u7559\u7A7A = \u6C38\u4E0D\u8FC7\u671F\u3002"
|
|
404
|
+
},
|
|
405
|
+
reason: {
|
|
406
|
+
label: "\u539F\u56E0",
|
|
407
|
+
help: "\u59D4\u6D3E\u5B58\u5728\u7684\u539F\u56E0\uFF08\u4F8B\u5982\u201C\u5E74\u5047 5/26\u20135/30\u201D\uFF09\u3002\u4F1A\u8BB0\u5165\u6539\u6D3E\u5BA1\u8BA1\u884C\u3002"
|
|
408
|
+
},
|
|
409
|
+
organization_id: {
|
|
410
|
+
label: "\u7EC4\u7EC7",
|
|
411
|
+
help: "\u62E5\u6709\u6B64\u89C4\u5219\u7684\u79DF\u6237\uFF1B\u7559\u7A7A = \u5BF9\u8BE5\u59D4\u6D3E\u4EBA\u8DE8\u79DF\u6237\u751F\u6548\u3002"
|
|
412
|
+
},
|
|
413
|
+
created_at: {
|
|
414
|
+
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
415
|
+
},
|
|
416
|
+
updated_at: {
|
|
417
|
+
label: "\u66F4\u65B0\u65F6\u95F4"
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
_views: {
|
|
421
|
+
active: {
|
|
422
|
+
label: "\u751F\u6548\u4E2D",
|
|
423
|
+
emptyState: {
|
|
424
|
+
title: "\u6682\u65E0\u59D4\u6D3E",
|
|
425
|
+
message: "\u58F0\u660E\u4E00\u6761\u4E0D\u5728\u5C97\u59D4\u6D3E\uFF0C\u4F11\u5047\u671F\u95F4\u5BA1\u6279\u81EA\u52A8\u6539\u6D3E\u7ED9\u5019\u8865\u4EBA\u3002"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
331
429
|
}
|
|
332
430
|
};
|
|
333
431
|
}
|
|
@@ -482,6 +580,55 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
482
580
|
label: "\u3059\u3079\u3066"
|
|
483
581
|
}
|
|
484
582
|
}
|
|
583
|
+
},
|
|
584
|
+
sys_approval_delegation: {
|
|
585
|
+
label: "Approval Delegation",
|
|
586
|
+
pluralLabel: "Approval Delegations",
|
|
587
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
588
|
+
fields: {
|
|
589
|
+
id: {
|
|
590
|
+
label: "Delegation ID"
|
|
591
|
+
},
|
|
592
|
+
delegator_id: {
|
|
593
|
+
label: "Delegator",
|
|
594
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
595
|
+
},
|
|
596
|
+
delegate_id: {
|
|
597
|
+
label: "Delegate",
|
|
598
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
599
|
+
},
|
|
600
|
+
valid_from: {
|
|
601
|
+
label: "Valid From",
|
|
602
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
603
|
+
},
|
|
604
|
+
valid_until: {
|
|
605
|
+
label: "Valid Until",
|
|
606
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
607
|
+
},
|
|
608
|
+
reason: {
|
|
609
|
+
label: "Reason",
|
|
610
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
611
|
+
},
|
|
612
|
+
organization_id: {
|
|
613
|
+
label: "Organization",
|
|
614
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
615
|
+
},
|
|
616
|
+
created_at: {
|
|
617
|
+
label: "Created At"
|
|
618
|
+
},
|
|
619
|
+
updated_at: {
|
|
620
|
+
label: "Updated At"
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
_views: {
|
|
624
|
+
active: {
|
|
625
|
+
label: "Active",
|
|
626
|
+
emptyState: {
|
|
627
|
+
title: "No delegations",
|
|
628
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
485
632
|
}
|
|
486
633
|
};
|
|
487
634
|
}
|
|
@@ -636,6 +783,55 @@ var init_es_ES_objects_generated = __esm({
|
|
|
636
783
|
label: "Todas"
|
|
637
784
|
}
|
|
638
785
|
}
|
|
786
|
+
},
|
|
787
|
+
sys_approval_delegation: {
|
|
788
|
+
label: "Approval Delegation",
|
|
789
|
+
pluralLabel: "Approval Delegations",
|
|
790
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
791
|
+
fields: {
|
|
792
|
+
id: {
|
|
793
|
+
label: "Delegation ID"
|
|
794
|
+
},
|
|
795
|
+
delegator_id: {
|
|
796
|
+
label: "Delegator",
|
|
797
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
798
|
+
},
|
|
799
|
+
delegate_id: {
|
|
800
|
+
label: "Delegate",
|
|
801
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
802
|
+
},
|
|
803
|
+
valid_from: {
|
|
804
|
+
label: "Valid From",
|
|
805
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
806
|
+
},
|
|
807
|
+
valid_until: {
|
|
808
|
+
label: "Valid Until",
|
|
809
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
810
|
+
},
|
|
811
|
+
reason: {
|
|
812
|
+
label: "Reason",
|
|
813
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
814
|
+
},
|
|
815
|
+
organization_id: {
|
|
816
|
+
label: "Organization",
|
|
817
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
818
|
+
},
|
|
819
|
+
created_at: {
|
|
820
|
+
label: "Created At"
|
|
821
|
+
},
|
|
822
|
+
updated_at: {
|
|
823
|
+
label: "Updated At"
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
_views: {
|
|
827
|
+
active: {
|
|
828
|
+
label: "Active",
|
|
829
|
+
emptyState: {
|
|
830
|
+
title: "No delegations",
|
|
831
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
639
835
|
}
|
|
640
836
|
};
|
|
641
837
|
}
|
|
@@ -670,6 +866,7 @@ __export(index_exports, {
|
|
|
670
866
|
ApprovalsServicePlugin: () => ApprovalsServicePlugin,
|
|
671
867
|
SysApprovalAction: () => SysApprovalAction,
|
|
672
868
|
SysApprovalApprover: () => SysApprovalApprover,
|
|
869
|
+
SysApprovalDelegation: () => SysApprovalDelegation,
|
|
673
870
|
SysApprovalRequest: () => SysApprovalRequest,
|
|
674
871
|
registerApprovalNode: () => registerApprovalNode
|
|
675
872
|
});
|
|
@@ -864,7 +1061,163 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
864
1061
|
// human-readable CSV source of truth only.
|
|
865
1062
|
{ fields: ["status", "updated_at"] },
|
|
866
1063
|
{ fields: ["submitter_id", "status"] }
|
|
867
|
-
]
|
|
1064
|
+
],
|
|
1065
|
+
// Server-declared decision actions (objectui#2678 P2-4). The console's
|
|
1066
|
+
// generic action runtime renders and executes these wherever this object is
|
|
1067
|
+
// surfaced — the approvals inbox included — so new decision capabilities
|
|
1068
|
+
// (and their params) ship as metadata, not as hand-written buttons. Each
|
|
1069
|
+
// targets the existing approvals REST route; `{id}` resolves from the row
|
|
1070
|
+
// and `actorId` defaults to the caller server-side. The service remains the
|
|
1071
|
+
// authority on who may act (pending-approver check) — `visible` only trims
|
|
1072
|
+
// the obvious non-pending case.
|
|
1073
|
+
actions: [
|
|
1074
|
+
{
|
|
1075
|
+
name: "approval_approve",
|
|
1076
|
+
label: "Approve",
|
|
1077
|
+
icon: "check-circle",
|
|
1078
|
+
type: "api",
|
|
1079
|
+
method: "POST",
|
|
1080
|
+
target: "/api/v1/approvals/requests/{id}/approve",
|
|
1081
|
+
params: [
|
|
1082
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1083
|
+
],
|
|
1084
|
+
visible: 'record.status == "pending"',
|
|
1085
|
+
locations: ["record_section", "list_item"],
|
|
1086
|
+
successMessage: "Approved.",
|
|
1087
|
+
refreshAfter: true
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: "approval_reject",
|
|
1091
|
+
label: "Reject",
|
|
1092
|
+
icon: "x-circle",
|
|
1093
|
+
type: "api",
|
|
1094
|
+
method: "POST",
|
|
1095
|
+
target: "/api/v1/approvals/requests/{id}/reject",
|
|
1096
|
+
params: [
|
|
1097
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1098
|
+
],
|
|
1099
|
+
visible: 'record.status == "pending"',
|
|
1100
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1101
|
+
locations: ["record_section", "list_item"],
|
|
1102
|
+
successMessage: "Rejected.",
|
|
1103
|
+
refreshAfter: true
|
|
1104
|
+
},
|
|
1105
|
+
{
|
|
1106
|
+
name: "approval_reassign",
|
|
1107
|
+
label: "Reassign",
|
|
1108
|
+
icon: "arrow-right-left",
|
|
1109
|
+
type: "api",
|
|
1110
|
+
method: "POST",
|
|
1111
|
+
target: "/api/v1/approvals/requests/{id}/reassign",
|
|
1112
|
+
params: [
|
|
1113
|
+
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
|
|
1114
|
+
// the console resolves its lookup config (`reference_to: sys_user`) so the
|
|
1115
|
+
// dialog renders a real user picker, while `name: 'to'` overrides the
|
|
1116
|
+
// request-body key to the `to` the reassign route expects. This is a
|
|
1117
|
+
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
|
|
1118
|
+
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1119
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1120
|
+
],
|
|
1121
|
+
visible: 'record.status == "pending"',
|
|
1122
|
+
locations: ["record_section"],
|
|
1123
|
+
successMessage: "Reassigned.",
|
|
1124
|
+
refreshAfter: true
|
|
1125
|
+
},
|
|
1126
|
+
// ── Approver secondary decisions ────────────────────────────────
|
|
1127
|
+
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
1128
|
+
// actions on a pending request; the service is the authority on who may act,
|
|
1129
|
+
// so `visible` only trims the non-pending case (matching approve/reject).
|
|
1130
|
+
{
|
|
1131
|
+
name: "approval_send_back",
|
|
1132
|
+
label: "Send back",
|
|
1133
|
+
icon: "corner-up-left",
|
|
1134
|
+
type: "api",
|
|
1135
|
+
method: "POST",
|
|
1136
|
+
target: "/api/v1/approvals/requests/{id}/revise",
|
|
1137
|
+
params: [
|
|
1138
|
+
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1139
|
+
],
|
|
1140
|
+
visible: 'record.status == "pending"',
|
|
1141
|
+
locations: ["record_section"],
|
|
1142
|
+
successMessage: "Sent back for revision.",
|
|
1143
|
+
refreshAfter: true
|
|
1144
|
+
},
|
|
1145
|
+
{
|
|
1146
|
+
name: "approval_request_info",
|
|
1147
|
+
label: "Request info",
|
|
1148
|
+
icon: "help-circle",
|
|
1149
|
+
type: "api",
|
|
1150
|
+
method: "POST",
|
|
1151
|
+
target: "/api/v1/approvals/requests/{id}/request-info",
|
|
1152
|
+
params: [
|
|
1153
|
+
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1154
|
+
],
|
|
1155
|
+
visible: 'record.status == "pending"',
|
|
1156
|
+
locations: ["record_section"],
|
|
1157
|
+
successMessage: "Information requested.",
|
|
1158
|
+
refreshAfter: true
|
|
1159
|
+
},
|
|
1160
|
+
// ── Submitter continuity actions ────────────────────────────────
|
|
1161
|
+
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1162
|
+
// submitter's own levers, so `visible` gates on `submitter_id == ctx.user.id`
|
|
1163
|
+
// — the current user is exposed via the console's predicate scope. The
|
|
1164
|
+
// service re-checks ownership; the predicate keeps a non-submitter from ever
|
|
1165
|
+
// seeing a button they cannot use.
|
|
1166
|
+
{
|
|
1167
|
+
name: "approval_remind",
|
|
1168
|
+
label: "Send reminder",
|
|
1169
|
+
icon: "bell-ring",
|
|
1170
|
+
type: "api",
|
|
1171
|
+
method: "POST",
|
|
1172
|
+
target: "/api/v1/approvals/requests/{id}/remind",
|
|
1173
|
+
params: [
|
|
1174
|
+
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1175
|
+
],
|
|
1176
|
+
visible: 'record.status == "pending" && record.submitter_id == ctx.user.id',
|
|
1177
|
+
locations: ["record_section"],
|
|
1178
|
+
successMessage: "Reminder sent.",
|
|
1179
|
+
refreshAfter: true
|
|
1180
|
+
},
|
|
1181
|
+
{
|
|
1182
|
+
name: "approval_recall",
|
|
1183
|
+
label: "Recall",
|
|
1184
|
+
icon: "undo-2",
|
|
1185
|
+
type: "api",
|
|
1186
|
+
method: "POST",
|
|
1187
|
+
target: "/api/v1/approvals/requests/{id}/recall",
|
|
1188
|
+
params: [
|
|
1189
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1190
|
+
],
|
|
1191
|
+
// Recall applies while the request is live for the submitter — pending
|
|
1192
|
+
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1193
|
+
visible: '(record.status == "pending" || record.status == "returned") && record.submitter_id == ctx.user.id',
|
|
1194
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1195
|
+
locations: ["record_section"],
|
|
1196
|
+
successMessage: "Recalled.",
|
|
1197
|
+
refreshAfter: true
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
name: "approval_resubmit",
|
|
1201
|
+
label: "Resubmit",
|
|
1202
|
+
icon: "refresh-cw",
|
|
1203
|
+
type: "api",
|
|
1204
|
+
method: "POST",
|
|
1205
|
+
target: "/api/v1/approvals/requests/{id}/resubmit",
|
|
1206
|
+
params: [
|
|
1207
|
+
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1208
|
+
],
|
|
1209
|
+
visible: 'record.status == "returned" && record.submitter_id == ctx.user.id',
|
|
1210
|
+
locations: ["record_section"],
|
|
1211
|
+
successMessage: "Resubmitted.",
|
|
1212
|
+
refreshAfter: true
|
|
1213
|
+
}
|
|
1214
|
+
],
|
|
1215
|
+
enable: {
|
|
1216
|
+
// [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
|
|
1217
|
+
// (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
|
|
1218
|
+
// generic CRUD. Reads stay open.
|
|
1219
|
+
apiMethods: ["get", "list"]
|
|
1220
|
+
}
|
|
868
1221
|
});
|
|
869
1222
|
|
|
870
1223
|
// src/sys-approval-action.object.ts
|
|
@@ -942,8 +1295,9 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
942
1295
|
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
|
|
943
1296
|
// remind / request_info / comment are thread interactions — they never
|
|
944
1297
|
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
|
|
945
|
-
// revision and the later resubmission.
|
|
946
|
-
|
|
1298
|
+
// revision and the later resubmission. ooo_substitute (#1322 M1) is a
|
|
1299
|
+
// system-recorded reroute of an out-of-office approver — no flow movement.
|
|
1300
|
+
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
947
1301
|
{
|
|
948
1302
|
label: "Action",
|
|
949
1303
|
required: true,
|
|
@@ -956,6 +1310,13 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
956
1310
|
group: "Action"
|
|
957
1311
|
}),
|
|
958
1312
|
comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1313
|
+
attachments: import_data2.Field.file({
|
|
1314
|
+
label: "Attachments",
|
|
1315
|
+
required: false,
|
|
1316
|
+
multiple: true,
|
|
1317
|
+
group: "Action",
|
|
1318
|
+
description: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
1319
|
+
}),
|
|
959
1320
|
created_at: import_data2.Field.datetime({
|
|
960
1321
|
label: "Created At",
|
|
961
1322
|
required: true,
|
|
@@ -967,7 +1328,12 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
967
1328
|
indexes: [
|
|
968
1329
|
{ fields: ["request_id", "created_at"] },
|
|
969
1330
|
{ fields: ["request_id", "step_index", "action"] }
|
|
970
|
-
]
|
|
1331
|
+
],
|
|
1332
|
+
enable: {
|
|
1333
|
+
// [ADR-0103] Engine-owned append-only decision log: appended by the approval
|
|
1334
|
+
// engine (SYSTEM_CTX). Reads stay open.
|
|
1335
|
+
apiMethods: ["get", "list"]
|
|
1336
|
+
}
|
|
971
1337
|
});
|
|
972
1338
|
|
|
973
1339
|
// src/sys-approval-approver.object.ts
|
|
@@ -1018,18 +1384,125 @@ var SysApprovalApprover = import_data3.ObjectSchema.create({
|
|
|
1018
1384
|
{ fields: ["approver", "organization_id"] },
|
|
1019
1385
|
// Sync path: rewrite all rows of one request on each approver-set change.
|
|
1020
1386
|
{ fields: ["request_id"] }
|
|
1021
|
-
]
|
|
1387
|
+
],
|
|
1388
|
+
enable: {
|
|
1389
|
+
// [ADR-0103] Engine-owned: approver rows are rewritten by the approval
|
|
1390
|
+
// engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
|
|
1391
|
+
apiMethods: ["get", "list"]
|
|
1392
|
+
}
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
// src/sys-approval-delegation.object.ts
|
|
1396
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
1397
|
+
var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
1398
|
+
name: "sys_approval_delegation",
|
|
1399
|
+
label: "Approval Delegation",
|
|
1400
|
+
pluralLabel: "Approval Delegations",
|
|
1401
|
+
icon: "user-clock",
|
|
1402
|
+
isSystem: true,
|
|
1403
|
+
managedBy: "system",
|
|
1404
|
+
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
|
|
1405
|
+
// authors their own out-of-office delegation. Affordance only (matches the
|
|
1406
|
+
// full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
|
|
1407
|
+
// keeps the system write guard from rejecting the self-service write.
|
|
1408
|
+
userActions: { create: true, edit: true, delete: true },
|
|
1409
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1410
|
+
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1411
|
+
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
1412
|
+
listViews: {
|
|
1413
|
+
active: {
|
|
1414
|
+
type: "grid",
|
|
1415
|
+
name: "active",
|
|
1416
|
+
label: "Active",
|
|
1417
|
+
data: { provider: "object", object: "sys_approval_delegation" },
|
|
1418
|
+
columns: ["delegator_id", "delegate_id", "valid_from", "valid_until", "reason"],
|
|
1419
|
+
sort: [{ field: "valid_until", order: "asc" }],
|
|
1420
|
+
pagination: { pageSize: 50 },
|
|
1421
|
+
emptyState: {
|
|
1422
|
+
title: "No delegations",
|
|
1423
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
fields: {
|
|
1428
|
+
id: import_data4.Field.text({ label: "Delegation ID", required: true, readonly: true, group: "System" }),
|
|
1429
|
+
delegator_id: import_data4.Field.lookup("sys_user", {
|
|
1430
|
+
label: "Delegator",
|
|
1431
|
+
required: true,
|
|
1432
|
+
group: "Delegation",
|
|
1433
|
+
description: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
1434
|
+
}),
|
|
1435
|
+
delegate_id: import_data4.Field.lookup("sys_user", {
|
|
1436
|
+
label: "Delegate",
|
|
1437
|
+
required: true,
|
|
1438
|
+
group: "Delegation",
|
|
1439
|
+
description: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
1440
|
+
}),
|
|
1441
|
+
valid_from: import_data4.Field.datetime({
|
|
1442
|
+
label: "Valid From",
|
|
1443
|
+
required: false,
|
|
1444
|
+
group: "Delegation",
|
|
1445
|
+
description: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
1446
|
+
}),
|
|
1447
|
+
valid_until: import_data4.Field.datetime({
|
|
1448
|
+
label: "Valid Until",
|
|
1449
|
+
required: false,
|
|
1450
|
+
group: "Delegation",
|
|
1451
|
+
description: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
1452
|
+
}),
|
|
1453
|
+
reason: import_data4.Field.text({
|
|
1454
|
+
label: "Reason",
|
|
1455
|
+
required: false,
|
|
1456
|
+
maxLength: 500,
|
|
1457
|
+
group: "Delegation",
|
|
1458
|
+
description: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
1459
|
+
}),
|
|
1460
|
+
organization_id: import_data4.Field.lookup("sys_organization", {
|
|
1461
|
+
label: "Organization",
|
|
1462
|
+
required: false,
|
|
1463
|
+
group: "System",
|
|
1464
|
+
description: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
1465
|
+
}),
|
|
1466
|
+
created_at: import_data4.Field.datetime({
|
|
1467
|
+
label: "Created At",
|
|
1468
|
+
defaultValue: "NOW()",
|
|
1469
|
+
readonly: true,
|
|
1470
|
+
group: "System"
|
|
1471
|
+
}),
|
|
1472
|
+
updated_at: import_data4.Field.datetime({
|
|
1473
|
+
label: "Updated At",
|
|
1474
|
+
defaultValue: "NOW()",
|
|
1475
|
+
readonly: true,
|
|
1476
|
+
group: "System"
|
|
1477
|
+
})
|
|
1478
|
+
},
|
|
1479
|
+
indexes: [
|
|
1480
|
+
// Resolution-time lookup: "active delegations for this delegator".
|
|
1481
|
+
{ fields: ["delegator_id", "organization_id"] },
|
|
1482
|
+
{ fields: ["delegate_id"] },
|
|
1483
|
+
{ fields: ["valid_until"] }
|
|
1484
|
+
],
|
|
1485
|
+
enable: {
|
|
1486
|
+
trackHistory: true,
|
|
1487
|
+
searchable: true,
|
|
1488
|
+
apiEnabled: true,
|
|
1489
|
+
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
1490
|
+
trash: true,
|
|
1491
|
+
mru: false
|
|
1492
|
+
}
|
|
1022
1493
|
});
|
|
1023
1494
|
|
|
1024
1495
|
// src/approval-service.ts
|
|
1025
1496
|
var import_node_crypto = require("crypto");
|
|
1026
1497
|
var import_automation = require("@objectstack/spec/automation");
|
|
1498
|
+
var import_core = require("@objectstack/core");
|
|
1027
1499
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1028
1500
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1029
1501
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1030
1502
|
var SLA_ACTOR_ID = "system:sla";
|
|
1031
1503
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1032
1504
|
var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
|
|
1505
|
+
var OOO_MAX_CHAIN = 8;
|
|
1033
1506
|
function uid(prefix) {
|
|
1034
1507
|
const g = globalThis;
|
|
1035
1508
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1102,6 +1575,10 @@ function rowFromAction(row) {
|
|
|
1102
1575
|
action: row.action,
|
|
1103
1576
|
actor_id: row.actor_id ?? void 0,
|
|
1104
1577
|
comment: row.comment ?? void 0,
|
|
1578
|
+
// Decision attachments (#3266). The column shipped in #3268 but this
|
|
1579
|
+
// contract mapping didn't — the raw engine row carried the fileIds while
|
|
1580
|
+
// every consumer of listActions saw none (caught by browser verification).
|
|
1581
|
+
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
|
|
1105
1582
|
created_at: row.created_at ?? void 0
|
|
1106
1583
|
};
|
|
1107
1584
|
}
|
|
@@ -1126,8 +1603,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1126
1603
|
async notify(input) {
|
|
1127
1604
|
const audience = input.audience.filter((a) => a && !a.includes(":"));
|
|
1128
1605
|
if (!this.messaging || !audience.length) return 0;
|
|
1606
|
+
let payload = input.payload;
|
|
1607
|
+
if (payload?.actionUrl === "/system/approvals" && input.source?.object === "sys_approval_request" && input.source.id) {
|
|
1608
|
+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
|
|
1609
|
+
}
|
|
1129
1610
|
try {
|
|
1130
|
-
await this.messaging.emit({ severity: "info", ...input, audience });
|
|
1611
|
+
await this.messaging.emit({ severity: "info", ...input, payload, audience });
|
|
1131
1612
|
return audience.length;
|
|
1132
1613
|
} catch (err) {
|
|
1133
1614
|
this.logger?.warn?.("[approvals] notification failed", {
|
|
@@ -1152,10 +1633,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1152
1633
|
}
|
|
1153
1634
|
/**
|
|
1154
1635
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1155
|
-
* graph tables for `team:` / `department:` / `position:` /
|
|
1156
|
-
* `manager:` approver types. Falls back to a
|
|
1157
|
-
* (`type:value`) when graph lookups produce nothing — so
|
|
1158
|
-
* and flows that rely on substring matching keep working.
|
|
1636
|
+
* graph tables for `team:` / `department:` / `position:` /
|
|
1637
|
+
* `org_membership_level:` / `manager:` approver types. Falls back to a
|
|
1638
|
+
* prefixed literal (`type:value`) when graph lookups produce nothing — so
|
|
1639
|
+
* existing fixtures and flows that rely on substring matching keep working.
|
|
1159
1640
|
*
|
|
1160
1641
|
* **Graph semantics:**
|
|
1161
1642
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
@@ -1163,66 +1644,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1163
1644
|
* → members of every descendant via `sys_business_unit_member`
|
|
1164
1645
|
* - `position` → holders via `sys_user_position` ∪ `sys_member.role`
|
|
1165
1646
|
* transition source (ADR-0090 D3 / ADR-0057 D4)
|
|
1166
|
-
* - `
|
|
1647
|
+
* - `org_membership_level`
|
|
1648
|
+
* → users with `sys_member.role = value` in tenant — the
|
|
1167
1649
|
* better-auth MEMBERSHIP TIER (owner/admin/member), not a
|
|
1168
1650
|
* position; author `position` for org positions
|
|
1169
1651
|
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
1170
1652
|
* - `field` → literal user id stored in `record[value]`
|
|
1171
1653
|
* - `user` → literal value
|
|
1654
|
+
*
|
|
1655
|
+
* `role` is accepted as the deprecated spelling of `org_membership_level`
|
|
1656
|
+
* (ADR-0090 D3) for one window: it resolves identically and logs a warning.
|
|
1657
|
+
*
|
|
1658
|
+
* **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
|
|
1659
|
+
* resolve to a specific person (`user` / `field` / `manager`) — are passed
|
|
1660
|
+
* through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
|
|
1661
|
+
* an active delegate when the resolved user has declared OOO. Group/graph
|
|
1662
|
+
* approvers (`team` / `department` / `position` / `org_membership_level`) are
|
|
1663
|
+
* left untouched: a group still has its other members, and position-routed
|
|
1664
|
+
* leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
|
|
1665
|
+
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1172
1666
|
*/
|
|
1173
|
-
async expandApprovers(step, record, organizationId) {
|
|
1667
|
+
async expandApprovers(step, record, organizationId, opts) {
|
|
1668
|
+
var _a;
|
|
1174
1669
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1670
|
+
const now = opts?.now ?? this.clock.now().getTime();
|
|
1175
1671
|
const out = [];
|
|
1176
|
-
|
|
1672
|
+
const specs = step.approvers;
|
|
1673
|
+
for (let idx = 0; idx < specs.length; idx++) {
|
|
1674
|
+
const a = specs[idx];
|
|
1177
1675
|
if (!a) continue;
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1676
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1677
|
+
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
1678
|
+
for (const u of ids) {
|
|
1679
|
+
if (!u) continue;
|
|
1680
|
+
out.push(u);
|
|
1681
|
+
if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
|
|
1181
1682
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1683
|
+
}
|
|
1684
|
+
return out.filter(Boolean);
|
|
1685
|
+
}
|
|
1686
|
+
/**
|
|
1687
|
+
* Resolve ONE approver spec to concrete approver identities, applying OOO
|
|
1688
|
+
* substitution (#1322) to individually-routed types. Extracted from
|
|
1689
|
+
* {@link ApprovalService.expandApprovers} so the caller can tag each spec's
|
|
1690
|
+
* resolved ids with a group (#3266) without duplicating the resolution logic.
|
|
1691
|
+
* Returns the `type:value` literal as a single-element fallback when a graph
|
|
1692
|
+
* lookup yields nothing — same behaviour as before the extraction.
|
|
1693
|
+
*/
|
|
1694
|
+
async resolveApproverSpec(a, record, organizationId, now, substitutions) {
|
|
1695
|
+
const type = (0, import_automation.canonicalApproverType)(String(a.type));
|
|
1696
|
+
if (type !== a.type) {
|
|
1697
|
+
this.logger?.warn?.(
|
|
1698
|
+
`[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) \u2014 author '${type}' instead`,
|
|
1699
|
+
{ deprecated: a.type, canonical: type }
|
|
1700
|
+
);
|
|
1701
|
+
}
|
|
1702
|
+
if (type === "user") {
|
|
1703
|
+
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1704
|
+
}
|
|
1705
|
+
if (type === "field" && record) {
|
|
1706
|
+
return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
if (type === "team") {
|
|
1710
|
+
const users = await this.expandTeamUsers(String(a.value));
|
|
1711
|
+
if (users.length) return users;
|
|
1712
|
+
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1713
|
+
const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
|
|
1714
|
+
if (users.length) return users;
|
|
1715
|
+
} else if (type === "position") {
|
|
1716
|
+
const users = await this.expandPositionUsers(String(a.value), organizationId);
|
|
1717
|
+
if (users.length) return users;
|
|
1718
|
+
} else if (type === "org_membership_level") {
|
|
1719
|
+
const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
|
|
1720
|
+
if (users.length) return users;
|
|
1721
|
+
} else if (type === "manager" && record) {
|
|
1722
|
+
const subject = record[a.value] ?? record.owner_id;
|
|
1723
|
+
if (subject) {
|
|
1724
|
+
const mgr = await this.lookupManager(String(subject));
|
|
1725
|
+
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
1220
1726
|
}
|
|
1221
|
-
} catch {
|
|
1222
1727
|
}
|
|
1223
|
-
|
|
1728
|
+
} catch {
|
|
1224
1729
|
}
|
|
1225
|
-
return
|
|
1730
|
+
return [`${a.type}:${a.value}`];
|
|
1226
1731
|
}
|
|
1227
1732
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1228
1733
|
async expandTeamUsers(teamId) {
|
|
@@ -1314,13 +1819,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1314
1819
|
}
|
|
1315
1820
|
} catch {
|
|
1316
1821
|
}
|
|
1317
|
-
for (const uid2 of await this.
|
|
1822
|
+
for (const uid2 of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid2);
|
|
1318
1823
|
return Array.from(users);
|
|
1319
1824
|
}
|
|
1320
|
-
/**
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1825
|
+
/**
|
|
1826
|
+
* better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
|
|
1827
|
+
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
1828
|
+
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
1829
|
+
* and stays, the platform-facing word does not.
|
|
1830
|
+
*/
|
|
1831
|
+
async expandMembershipTierUsers(tier, organizationId) {
|
|
1832
|
+
if (!tier) return [];
|
|
1833
|
+
const filter = { role: tier };
|
|
1324
1834
|
if (organizationId) filter.organization_id = organizationId;
|
|
1325
1835
|
let rows = [];
|
|
1326
1836
|
try {
|
|
@@ -1344,6 +1854,62 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1344
1854
|
return null;
|
|
1345
1855
|
}
|
|
1346
1856
|
}
|
|
1857
|
+
/**
|
|
1858
|
+
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
1859
|
+
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
1860
|
+
* slot should actually go to — the delegate acts under their own identity, so
|
|
1861
|
+
* no impersonation is involved. Returns `[userId]` unchanged when there is no
|
|
1862
|
+
* active delegation. Each hop is appended to `collector` (when supplied) so
|
|
1863
|
+
* the caller can audit + notify (M4).
|
|
1864
|
+
*
|
|
1865
|
+
* The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
|
|
1866
|
+
* stops on a self-reference or a cycle, so a mis-declared loop degrades to the
|
|
1867
|
+
* last reachable delegate rather than hanging.
|
|
1868
|
+
*/
|
|
1869
|
+
async applyOooDelegation(userId, now, organizationId, collector) {
|
|
1870
|
+
const start = String(userId ?? "").trim();
|
|
1871
|
+
if (!start) return [];
|
|
1872
|
+
let current = start;
|
|
1873
|
+
const visited = /* @__PURE__ */ new Set([current]);
|
|
1874
|
+
for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
|
|
1875
|
+
const del = await this.lookupActiveDelegation(current, now, organizationId);
|
|
1876
|
+
if (!del) break;
|
|
1877
|
+
const to = String(del.delegate_id ?? "").trim();
|
|
1878
|
+
if (!to || to === current || visited.has(to)) break;
|
|
1879
|
+
collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
|
|
1880
|
+
visited.add(to);
|
|
1881
|
+
current = to;
|
|
1882
|
+
}
|
|
1883
|
+
return [current];
|
|
1884
|
+
}
|
|
1885
|
+
/**
|
|
1886
|
+
* The active OOO delegation for a delegator at `now`, or null. Validity is the
|
|
1887
|
+
* shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
|
|
1888
|
+
* resolution time — never by a background job. When several rows are active,
|
|
1889
|
+
* the one expiring soonest wins (the most specific coverage window).
|
|
1890
|
+
*/
|
|
1891
|
+
async lookupActiveDelegation(delegatorId, now, organizationId) {
|
|
1892
|
+
if (!delegatorId) return null;
|
|
1893
|
+
let rows = [];
|
|
1894
|
+
try {
|
|
1895
|
+
rows = await this.engine.find("sys_approval_delegation", {
|
|
1896
|
+
filter: { delegator_id: delegatorId },
|
|
1897
|
+
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1898
|
+
limit: 50,
|
|
1899
|
+
context: SYSTEM_CTX
|
|
1900
|
+
});
|
|
1901
|
+
} catch {
|
|
1902
|
+
return null;
|
|
1903
|
+
}
|
|
1904
|
+
const active = (rows ?? []).filter((r) => (0, import_core.isGrantActive)(r, now) && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
|
|
1905
|
+
if (!active.length) return null;
|
|
1906
|
+
active.sort((a, b) => {
|
|
1907
|
+
const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1908
|
+
const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1909
|
+
return au - bu;
|
|
1910
|
+
});
|
|
1911
|
+
return active[0];
|
|
1912
|
+
}
|
|
1347
1913
|
/** Mirror a request status onto a business-object field, if configured. */
|
|
1348
1914
|
async mirrorStatusField(object, recordId, field, status) {
|
|
1349
1915
|
try {
|
|
@@ -1377,13 +1943,24 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1377
1943
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
1378
1944
|
}
|
|
1379
1945
|
const ctxOrg = context?.organizationId ?? context?.tenantId ?? input.organizationId ?? null;
|
|
1380
|
-
const
|
|
1381
|
-
const
|
|
1946
|
+
const nowDate = this.clock.now();
|
|
1947
|
+
const substitutions = [];
|
|
1948
|
+
const groups = {};
|
|
1949
|
+
const approvers = await this.expandApprovers(
|
|
1950
|
+
{ approvers: input.config.approvers },
|
|
1951
|
+
input.record,
|
|
1952
|
+
ctxOrg,
|
|
1953
|
+
{ now: nowDate.getTime(), substitutions, groups }
|
|
1954
|
+
);
|
|
1955
|
+
const now = nowDate.toISOString();
|
|
1382
1956
|
const id = uid("areq");
|
|
1383
1957
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1384
1958
|
const configSnapshot = { ...input.config };
|
|
1385
1959
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1386
1960
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1961
|
+
if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
|
|
1962
|
+
configSnapshot.__approverGroups = groups;
|
|
1963
|
+
}
|
|
1387
1964
|
try {
|
|
1388
1965
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1389
1966
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
@@ -1425,17 +2002,83 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1425
2002
|
comment: null,
|
|
1426
2003
|
created_at: now
|
|
1427
2004
|
}, { context: SYSTEM_CTX });
|
|
2005
|
+
for (const sub of substitutions) {
|
|
2006
|
+
await this.engine.insert("sys_approval_action", {
|
|
2007
|
+
id: uid("aact"),
|
|
2008
|
+
request_id: id,
|
|
2009
|
+
organization_id: ctxOrg,
|
|
2010
|
+
step_name: input.nodeId,
|
|
2011
|
+
step_index: 0,
|
|
2012
|
+
action: "ooo_substitute",
|
|
2013
|
+
actor_id: null,
|
|
2014
|
+
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2015
|
+
created_at: now
|
|
2016
|
+
}, { context: SYSTEM_CTX });
|
|
2017
|
+
await this.notify({
|
|
2018
|
+
topic: "approval.ooo_substituted",
|
|
2019
|
+
audience: [sub.to],
|
|
2020
|
+
source: { object: "sys_approval_request", id },
|
|
2021
|
+
dedupKey: `approval-ooo-${id}-${sub.to}`,
|
|
2022
|
+
payload: {
|
|
2023
|
+
title: "Approval routed to you (out-of-office cover)",
|
|
2024
|
+
message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
|
|
2025
|
+
actionUrl: "/system/approvals"
|
|
2026
|
+
}
|
|
2027
|
+
});
|
|
2028
|
+
await this.notify({
|
|
2029
|
+
topic: "approval.ooo_skipped",
|
|
2030
|
+
audience: [sub.from],
|
|
2031
|
+
source: { object: "sys_approval_request", id },
|
|
2032
|
+
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
|
|
2033
|
+
payload: {
|
|
2034
|
+
title: "Approval routed to your delegate",
|
|
2035
|
+
message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
|
|
2036
|
+
actionUrl: "/system/approvals"
|
|
2037
|
+
}
|
|
2038
|
+
});
|
|
2039
|
+
}
|
|
1428
2040
|
if (input.config.approvalStatusField) {
|
|
1429
2041
|
await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
|
|
1430
2042
|
}
|
|
1431
2043
|
return rowFromRequest(row);
|
|
1432
2044
|
}
|
|
1433
2045
|
/**
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
2046
|
+
* True when the approve tally satisfies the node's `behavior` (#3266):
|
|
2047
|
+
* - `unanimous` — every resolved approver approved.
|
|
2048
|
+
* - `quorum` — at least `minApprovals` distinct approvals (default = all).
|
|
2049
|
+
* - `per_group` — every group reached `minApprovals` approvals (default 1).
|
|
2050
|
+
* Thresholds are clamped to the resolvable count / group size, so a mis-set
|
|
2051
|
+
* value can never deadlock a request.
|
|
2052
|
+
*/
|
|
2053
|
+
isApprovalSatisfied(behavior, config, original, groupMap, approved) {
|
|
2054
|
+
if (behavior === "unanimous") {
|
|
2055
|
+
return original.length > 0 && original.every((a) => approved.has(a));
|
|
2056
|
+
}
|
|
2057
|
+
if (behavior === "quorum") {
|
|
2058
|
+
const n = original.length || 1;
|
|
2059
|
+
const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
|
|
2060
|
+
return approved.size >= need;
|
|
2061
|
+
}
|
|
2062
|
+
if (behavior === "per_group") {
|
|
2063
|
+
const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
|
|
2064
|
+
const size = {};
|
|
2065
|
+
for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
2066
|
+
const groups = Object.keys(size);
|
|
2067
|
+
if (!groups.length) return true;
|
|
2068
|
+
const got = {};
|
|
2069
|
+
for (const a of approved) for (const g of groupMap[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
2070
|
+
return groups.every((g) => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
|
|
2071
|
+
}
|
|
2072
|
+
return true;
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Record a decision on a node-driven request. Honours the node's `behavior`
|
|
2076
|
+
* (#3266): `first_response` finalizes on the first approval; `unanimous`,
|
|
2077
|
+
* `quorum`, and `per_group` hold the request open until their tally is met
|
|
2078
|
+
* (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
|
|
2079
|
+
* finalizes the node (one veto). When the request finalizes, returns the
|
|
2080
|
+
* suspended run id + node id so the caller (or {@link ApprovalService.decide})
|
|
2081
|
+
* can resume the flow down the matching branch.
|
|
1439
2082
|
*/
|
|
1440
2083
|
async decideNode(requestId, input, context) {
|
|
1441
2084
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -1469,22 +2112,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1469
2112
|
action: input.decision,
|
|
1470
2113
|
actor_id: input.actorId,
|
|
1471
2114
|
comment: input.comment ?? null,
|
|
2115
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
1472
2116
|
created_at: now
|
|
1473
2117
|
}, { context: SYSTEM_CTX });
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
{ approvers: config.approvers },
|
|
1477
|
-
parseJson(raw.payload_json, void 0),
|
|
1478
|
-
org
|
|
1479
|
-
);
|
|
2118
|
+
const behavior = config.behavior ?? "first_response";
|
|
2119
|
+
if (input.decision === "approve" && behavior !== "first_response") {
|
|
1480
2120
|
const acts = await this.engine.find("sys_approval_action", {
|
|
1481
2121
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
1482
|
-
limit:
|
|
2122
|
+
limit: 1e3,
|
|
1483
2123
|
context: SYSTEM_CTX
|
|
1484
2124
|
});
|
|
1485
2125
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
1486
|
-
const
|
|
1487
|
-
|
|
2126
|
+
const snapshotGroups = config.__approverGroups;
|
|
2127
|
+
let original;
|
|
2128
|
+
let groupMap;
|
|
2129
|
+
if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
|
|
2130
|
+
groupMap = snapshotGroups;
|
|
2131
|
+
original = Object.keys(snapshotGroups);
|
|
2132
|
+
} else {
|
|
2133
|
+
original = await this.expandApprovers(
|
|
2134
|
+
{ approvers: config.approvers },
|
|
2135
|
+
parseJson(raw.payload_json, void 0),
|
|
2136
|
+
org
|
|
2137
|
+
);
|
|
2138
|
+
groupMap = {};
|
|
2139
|
+
}
|
|
2140
|
+
if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
|
|
2141
|
+
const stillPending = original.filter((a) => !approved.has(a));
|
|
1488
2142
|
await this.engine.update("sys_approval_request", {
|
|
1489
2143
|
id: requestId,
|
|
1490
2144
|
pending_approvers: stillPending.join(","),
|
|
@@ -1918,10 +2572,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1918
2572
|
comment: input.comment ?? `${from} \u2192 ${to}`,
|
|
1919
2573
|
created_at: now
|
|
1920
2574
|
}, { context: SYSTEM_CTX });
|
|
2575
|
+
let configPatch = {};
|
|
2576
|
+
try {
|
|
2577
|
+
const cfg = parseJson(raw.node_config_json, null);
|
|
2578
|
+
const groups = cfg?.__approverGroups;
|
|
2579
|
+
if (groups && groups[from] && !groups[to]) {
|
|
2580
|
+
groups[to] = groups[from];
|
|
2581
|
+
delete groups[from];
|
|
2582
|
+
configPatch = { node_config_json: JSON.stringify(cfg) };
|
|
2583
|
+
}
|
|
2584
|
+
} catch {
|
|
2585
|
+
}
|
|
1921
2586
|
await this.engine.update("sys_approval_request", {
|
|
1922
2587
|
id: requestId,
|
|
1923
2588
|
pending_approvers: next.join(","),
|
|
1924
|
-
updated_at: now
|
|
2589
|
+
updated_at: now,
|
|
2590
|
+
...configPatch
|
|
1925
2591
|
}, { context: SYSTEM_CTX });
|
|
1926
2592
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
1927
2593
|
await this.notify({
|
|
@@ -2166,6 +2832,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2166
2832
|
action: "comment",
|
|
2167
2833
|
actor_id: input.actorId,
|
|
2168
2834
|
comment: input.comment.trim(),
|
|
2835
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
2169
2836
|
created_at: now
|
|
2170
2837
|
}, { context: SYSTEM_CTX });
|
|
2171
2838
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
@@ -2723,8 +3390,54 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2723
3390
|
const row = rowFromRequest(rows[0]);
|
|
2724
3391
|
await this.enrichRows([row]);
|
|
2725
3392
|
await this.attachFlowSteps(row);
|
|
3393
|
+
await this.attachDecisionProgress(row, rows[0]);
|
|
2726
3394
|
return row;
|
|
2727
3395
|
}
|
|
3396
|
+
/**
|
|
3397
|
+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
|
|
3398
|
+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
|
|
3399
|
+
* for a PENDING request whose behavior aggregates multiple approvals
|
|
3400
|
+
* (`unanimous` / `quorum` / `per_group`), expose
|
|
3401
|
+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
|
|
3402
|
+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
|
|
3403
|
+
* `first_response` requests carry no progress (one approval finalizes).
|
|
3404
|
+
* Display-only and best-effort — errors leave the row untouched.
|
|
3405
|
+
*/
|
|
3406
|
+
async attachDecisionProgress(row, raw) {
|
|
3407
|
+
try {
|
|
3408
|
+
if (row.status !== "pending") return;
|
|
3409
|
+
const cfg = parseJson(raw.node_config_json, void 0);
|
|
3410
|
+
const behavior = cfg?.behavior ?? "first_response";
|
|
3411
|
+
if (behavior !== "unanimous" && behavior !== "quorum" && behavior !== "per_group") return;
|
|
3412
|
+
const acts = await this.engine.find("sys_approval_action", {
|
|
3413
|
+
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3414
|
+
limit: 1e3,
|
|
3415
|
+
context: SYSTEM_CTX
|
|
3416
|
+
});
|
|
3417
|
+
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3418
|
+
const snapshot = cfg?.__approverGroups;
|
|
3419
|
+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...row.pending_approvers ?? []];
|
|
3420
|
+
const total = slate.length || 1;
|
|
3421
|
+
const progress = { behavior, got: approved.size, need: total };
|
|
3422
|
+
if (behavior === "quorum") {
|
|
3423
|
+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
|
|
3424
|
+
} else if (behavior === "per_group" && snapshot) {
|
|
3425
|
+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
|
|
3426
|
+
const size = {};
|
|
3427
|
+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
3428
|
+
const got = {};
|
|
3429
|
+
for (const a of approved) for (const g of snapshot[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
3430
|
+
progress.groups = Object.keys(size).sort().map((g) => {
|
|
3431
|
+
const need = Math.min(perGroupNeed, size[g]);
|
|
3432
|
+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
|
|
3433
|
+
});
|
|
3434
|
+
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3435
|
+
progress.need = progress.groups.length;
|
|
3436
|
+
}
|
|
3437
|
+
row.decision_progress = progress;
|
|
3438
|
+
} catch {
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
2728
3441
|
/**
|
|
2729
3442
|
* Derive approval-step progress from the owning flow's graph (single-read
|
|
2730
3443
|
* enrichment only — list reads skip it). Walks from the start node
|
|
@@ -2786,8 +3499,8 @@ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
|
2786
3499
|
var ApprovalService = _ApprovalService;
|
|
2787
3500
|
|
|
2788
3501
|
// src/sys-approval-token.object.ts
|
|
2789
|
-
var
|
|
2790
|
-
var SysApprovalToken =
|
|
3502
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
3503
|
+
var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
2791
3504
|
name: "sys_approval_token",
|
|
2792
3505
|
label: "Approval Action Token",
|
|
2793
3506
|
pluralLabel: "Approval Action Tokens",
|
|
@@ -2799,13 +3512,13 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2799
3512
|
nameField: "id",
|
|
2800
3513
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
2801
3514
|
fields: {
|
|
2802
|
-
id:
|
|
2803
|
-
organization_id:
|
|
3515
|
+
id: import_data5.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
|
|
3516
|
+
organization_id: import_data5.Field.lookup("sys_organization", {
|
|
2804
3517
|
label: "Organization",
|
|
2805
3518
|
required: false,
|
|
2806
3519
|
group: "System"
|
|
2807
3520
|
}),
|
|
2808
|
-
token_hash:
|
|
3521
|
+
token_hash: import_data5.Field.text({
|
|
2809
3522
|
label: "Token Hash",
|
|
2810
3523
|
required: true,
|
|
2811
3524
|
maxLength: 100,
|
|
@@ -2813,20 +3526,20 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2813
3526
|
description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
|
|
2814
3527
|
group: "Token"
|
|
2815
3528
|
}),
|
|
2816
|
-
request_id:
|
|
3529
|
+
request_id: import_data5.Field.text({
|
|
2817
3530
|
label: "Request",
|
|
2818
3531
|
required: true,
|
|
2819
3532
|
maxLength: 100,
|
|
2820
3533
|
readonly: true,
|
|
2821
3534
|
group: "Token"
|
|
2822
3535
|
}),
|
|
2823
|
-
action:
|
|
3536
|
+
action: import_data5.Field.select(["approve", "reject"], {
|
|
2824
3537
|
label: "Action",
|
|
2825
3538
|
required: true,
|
|
2826
3539
|
readonly: true,
|
|
2827
3540
|
group: "Token"
|
|
2828
3541
|
}),
|
|
2829
|
-
approver_id:
|
|
3542
|
+
approver_id: import_data5.Field.text({
|
|
2830
3543
|
label: "Approver",
|
|
2831
3544
|
required: true,
|
|
2832
3545
|
maxLength: 200,
|
|
@@ -2834,18 +3547,18 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2834
3547
|
description: "Identity the token is bound to; the decision is audited as this approver",
|
|
2835
3548
|
group: "Token"
|
|
2836
3549
|
}),
|
|
2837
|
-
expires_at:
|
|
3550
|
+
expires_at: import_data5.Field.datetime({
|
|
2838
3551
|
label: "Expires At",
|
|
2839
3552
|
required: true,
|
|
2840
3553
|
readonly: true,
|
|
2841
3554
|
group: "Lifecycle"
|
|
2842
3555
|
}),
|
|
2843
|
-
consumed_at:
|
|
3556
|
+
consumed_at: import_data5.Field.datetime({
|
|
2844
3557
|
label: "Consumed At",
|
|
2845
3558
|
required: false,
|
|
2846
3559
|
group: "Lifecycle"
|
|
2847
3560
|
}),
|
|
2848
|
-
created_at:
|
|
3561
|
+
created_at: import_data5.Field.datetime({
|
|
2849
3562
|
label: "Created At",
|
|
2850
3563
|
required: true,
|
|
2851
3564
|
defaultValue: "NOW()",
|
|
@@ -2856,7 +3569,12 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2856
3569
|
indexes: [
|
|
2857
3570
|
{ fields: ["token_hash"] },
|
|
2858
3571
|
{ fields: ["request_id"] }
|
|
2859
|
-
]
|
|
3572
|
+
],
|
|
3573
|
+
enable: {
|
|
3574
|
+
// [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
|
|
3575
|
+
// consumed by the approval engine (SYSTEM_CTX), never via the data API.
|
|
3576
|
+
apiMethods: ["get", "list"]
|
|
3577
|
+
}
|
|
2860
3578
|
});
|
|
2861
3579
|
|
|
2862
3580
|
// src/action-link-pages.ts
|
|
@@ -2980,6 +3698,40 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
2980
3698
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
2981
3699
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
2982
3700
|
}
|
|
3701
|
+
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
3702
|
+
function bindDelegationWriteGuard(engine, logger) {
|
|
3703
|
+
const makeGuard = (isInsert) => async (ctx) => {
|
|
3704
|
+
const session = ctx?.session ?? {};
|
|
3705
|
+
if (session.isSystem) return;
|
|
3706
|
+
const roles = session.roles ?? [];
|
|
3707
|
+
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3708
|
+
const userId = session.userId != null ? String(session.userId) : "";
|
|
3709
|
+
const data = ctx?.input?.data;
|
|
3710
|
+
const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
|
|
3711
|
+
const deny = () => {
|
|
3712
|
+
const err = new Error(
|
|
3713
|
+
"FORBIDDEN: you may only manage out-of-office delegations where you are the delegator" + (userId ? ` ('${userId}')` : "")
|
|
3714
|
+
);
|
|
3715
|
+
err.code = "FORBIDDEN";
|
|
3716
|
+
err.statusCode = 403;
|
|
3717
|
+
throw err;
|
|
3718
|
+
};
|
|
3719
|
+
for (const row of rows) {
|
|
3720
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
3721
|
+
const has = Object.prototype.hasOwnProperty.call(row, "delegator_id");
|
|
3722
|
+
const supplied = has ? String(row.delegator_id ?? "") : "";
|
|
3723
|
+
if (isInsert && (!has || supplied === "")) {
|
|
3724
|
+
if (!userId) deny();
|
|
3725
|
+
row.delegator_id = userId;
|
|
3726
|
+
continue;
|
|
3727
|
+
}
|
|
3728
|
+
if (has && supplied !== userId) deny();
|
|
3729
|
+
}
|
|
3730
|
+
};
|
|
3731
|
+
engine.registerHook("beforeInsert", makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3732
|
+
engine.registerHook("beforeUpdate", makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3733
|
+
logger?.info?.("[approvals] delegation write-guard bound");
|
|
3734
|
+
}
|
|
2983
3735
|
function unbindAllHooks(engine) {
|
|
2984
3736
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
2985
3737
|
}
|
|
@@ -3075,7 +3827,7 @@ var ApprovalsServicePlugin = class {
|
|
|
3075
3827
|
scope: "system",
|
|
3076
3828
|
defaultDatasource: "cloud",
|
|
3077
3829
|
namespace: "sys",
|
|
3078
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
|
|
3830
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
|
|
3079
3831
|
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
3080
3832
|
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
3081
3833
|
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
@@ -3086,7 +3838,8 @@ var ApprovalsServicePlugin = class {
|
|
|
3086
3838
|
priority: 100,
|
|
3087
3839
|
items: [
|
|
3088
3840
|
{ id: "nav_approval_requests", type: "object", label: "Requests", objectName: "sys_approval_request", icon: "inbox", requiresObject: "sys_approval_request" },
|
|
3089
|
-
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" }
|
|
3841
|
+
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" },
|
|
3842
|
+
{ id: "nav_approval_delegations", type: "object", label: "Delegations (OOO)", objectName: "sys_approval_delegation", icon: "user-clock", requiresObject: "sys_approval_delegation" }
|
|
3090
3843
|
]
|
|
3091
3844
|
}
|
|
3092
3845
|
]
|
|
@@ -3132,8 +3885,9 @@ var ApprovalsServicePlugin = class {
|
|
|
3132
3885
|
try {
|
|
3133
3886
|
unbindAllHooks(engine);
|
|
3134
3887
|
bindApprovalLockHook(engine, ctx.logger);
|
|
3888
|
+
bindDelegationWriteGuard(engine, ctx.logger);
|
|
3135
3889
|
} catch (err) {
|
|
3136
|
-
ctx.logger.warn?.("[approvals] failed to bind
|
|
3890
|
+
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
3137
3891
|
}
|
|
3138
3892
|
}
|
|
3139
3893
|
ctx.registerService("approvals", this.service);
|
|
@@ -3251,6 +4005,7 @@ var ApprovalsServicePlugin = class {
|
|
|
3251
4005
|
ApprovalsServicePlugin,
|
|
3252
4006
|
SysApprovalAction,
|
|
3253
4007
|
SysApprovalApprover,
|
|
4008
|
+
SysApprovalDelegation,
|
|
3254
4009
|
SysApprovalRequest,
|
|
3255
4010
|
registerApprovalNode
|
|
3256
4011
|
});
|