@objectstack/plugin-approvals 15.1.1 → 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 +105 -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.mjs
CHANGED
|
@@ -162,6 +162,55 @@ var init_en_objects_generated = __esm({
|
|
|
162
162
|
label: "All"
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
|
+
},
|
|
166
|
+
sys_approval_delegation: {
|
|
167
|
+
label: "Approval Delegation",
|
|
168
|
+
pluralLabel: "Approval Delegations",
|
|
169
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
170
|
+
fields: {
|
|
171
|
+
id: {
|
|
172
|
+
label: "Delegation ID"
|
|
173
|
+
},
|
|
174
|
+
delegator_id: {
|
|
175
|
+
label: "Delegator",
|
|
176
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
177
|
+
},
|
|
178
|
+
delegate_id: {
|
|
179
|
+
label: "Delegate",
|
|
180
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
181
|
+
},
|
|
182
|
+
valid_from: {
|
|
183
|
+
label: "Valid From",
|
|
184
|
+
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."
|
|
185
|
+
},
|
|
186
|
+
valid_until: {
|
|
187
|
+
label: "Valid Until",
|
|
188
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
189
|
+
},
|
|
190
|
+
reason: {
|
|
191
|
+
label: "Reason",
|
|
192
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
193
|
+
},
|
|
194
|
+
organization_id: {
|
|
195
|
+
label: "Organization",
|
|
196
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
197
|
+
},
|
|
198
|
+
created_at: {
|
|
199
|
+
label: "Created At"
|
|
200
|
+
},
|
|
201
|
+
updated_at: {
|
|
202
|
+
label: "Updated At"
|
|
203
|
+
}
|
|
204
|
+
},
|
|
205
|
+
_views: {
|
|
206
|
+
active: {
|
|
207
|
+
label: "Active",
|
|
208
|
+
emptyState: {
|
|
209
|
+
title: "No delegations",
|
|
210
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
165
214
|
}
|
|
166
215
|
};
|
|
167
216
|
}
|
|
@@ -316,6 +365,55 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
316
365
|
label: "\u5168\u90E8"
|
|
317
366
|
}
|
|
318
367
|
}
|
|
368
|
+
},
|
|
369
|
+
sys_approval_delegation: {
|
|
370
|
+
label: "\u5BA1\u6279\u59D4\u6D3E",
|
|
371
|
+
pluralLabel: "\u5BA1\u6279\u59D4\u6D3E",
|
|
372
|
+
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",
|
|
373
|
+
fields: {
|
|
374
|
+
id: {
|
|
375
|
+
label: "\u59D4\u6D3E ID"
|
|
376
|
+
},
|
|
377
|
+
delegator_id: {
|
|
378
|
+
label: "\u59D4\u6D3E\u4EBA",
|
|
379
|
+
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"
|
|
380
|
+
},
|
|
381
|
+
delegate_id: {
|
|
382
|
+
label: "\u5019\u8865\u4EBA",
|
|
383
|
+
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"
|
|
384
|
+
},
|
|
385
|
+
valid_from: {
|
|
386
|
+
label: "\u751F\u6548\u65F6\u95F4",
|
|
387
|
+
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"
|
|
388
|
+
},
|
|
389
|
+
valid_until: {
|
|
390
|
+
label: "\u5931\u6548\u65F6\u95F4",
|
|
391
|
+
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"
|
|
392
|
+
},
|
|
393
|
+
reason: {
|
|
394
|
+
label: "\u539F\u56E0",
|
|
395
|
+
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"
|
|
396
|
+
},
|
|
397
|
+
organization_id: {
|
|
398
|
+
label: "\u7EC4\u7EC7",
|
|
399
|
+
help: "\u62E5\u6709\u6B64\u89C4\u5219\u7684\u79DF\u6237\uFF1B\u7559\u7A7A = \u5BF9\u8BE5\u59D4\u6D3E\u4EBA\u8DE8\u79DF\u6237\u751F\u6548\u3002"
|
|
400
|
+
},
|
|
401
|
+
created_at: {
|
|
402
|
+
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
403
|
+
},
|
|
404
|
+
updated_at: {
|
|
405
|
+
label: "\u66F4\u65B0\u65F6\u95F4"
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
_views: {
|
|
409
|
+
active: {
|
|
410
|
+
label: "\u751F\u6548\u4E2D",
|
|
411
|
+
emptyState: {
|
|
412
|
+
title: "\u6682\u65E0\u59D4\u6D3E",
|
|
413
|
+
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"
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
319
417
|
}
|
|
320
418
|
};
|
|
321
419
|
}
|
|
@@ -470,6 +568,55 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
470
568
|
label: "\u3059\u3079\u3066"
|
|
471
569
|
}
|
|
472
570
|
}
|
|
571
|
+
},
|
|
572
|
+
sys_approval_delegation: {
|
|
573
|
+
label: "Approval Delegation",
|
|
574
|
+
pluralLabel: "Approval Delegations",
|
|
575
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
576
|
+
fields: {
|
|
577
|
+
id: {
|
|
578
|
+
label: "Delegation ID"
|
|
579
|
+
},
|
|
580
|
+
delegator_id: {
|
|
581
|
+
label: "Delegator",
|
|
582
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
583
|
+
},
|
|
584
|
+
delegate_id: {
|
|
585
|
+
label: "Delegate",
|
|
586
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
587
|
+
},
|
|
588
|
+
valid_from: {
|
|
589
|
+
label: "Valid From",
|
|
590
|
+
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."
|
|
591
|
+
},
|
|
592
|
+
valid_until: {
|
|
593
|
+
label: "Valid Until",
|
|
594
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
595
|
+
},
|
|
596
|
+
reason: {
|
|
597
|
+
label: "Reason",
|
|
598
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
599
|
+
},
|
|
600
|
+
organization_id: {
|
|
601
|
+
label: "Organization",
|
|
602
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
603
|
+
},
|
|
604
|
+
created_at: {
|
|
605
|
+
label: "Created At"
|
|
606
|
+
},
|
|
607
|
+
updated_at: {
|
|
608
|
+
label: "Updated At"
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
_views: {
|
|
612
|
+
active: {
|
|
613
|
+
label: "Active",
|
|
614
|
+
emptyState: {
|
|
615
|
+
title: "No delegations",
|
|
616
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
}
|
|
473
620
|
}
|
|
474
621
|
};
|
|
475
622
|
}
|
|
@@ -624,6 +771,55 @@ var init_es_ES_objects_generated = __esm({
|
|
|
624
771
|
label: "Todas"
|
|
625
772
|
}
|
|
626
773
|
}
|
|
774
|
+
},
|
|
775
|
+
sys_approval_delegation: {
|
|
776
|
+
label: "Approval Delegation",
|
|
777
|
+
pluralLabel: "Approval Delegations",
|
|
778
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
779
|
+
fields: {
|
|
780
|
+
id: {
|
|
781
|
+
label: "Delegation ID"
|
|
782
|
+
},
|
|
783
|
+
delegator_id: {
|
|
784
|
+
label: "Delegator",
|
|
785
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
786
|
+
},
|
|
787
|
+
delegate_id: {
|
|
788
|
+
label: "Delegate",
|
|
789
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
790
|
+
},
|
|
791
|
+
valid_from: {
|
|
792
|
+
label: "Valid From",
|
|
793
|
+
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."
|
|
794
|
+
},
|
|
795
|
+
valid_until: {
|
|
796
|
+
label: "Valid Until",
|
|
797
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
798
|
+
},
|
|
799
|
+
reason: {
|
|
800
|
+
label: "Reason",
|
|
801
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
802
|
+
},
|
|
803
|
+
organization_id: {
|
|
804
|
+
label: "Organization",
|
|
805
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
806
|
+
},
|
|
807
|
+
created_at: {
|
|
808
|
+
label: "Created At"
|
|
809
|
+
},
|
|
810
|
+
updated_at: {
|
|
811
|
+
label: "Updated At"
|
|
812
|
+
}
|
|
813
|
+
},
|
|
814
|
+
_views: {
|
|
815
|
+
active: {
|
|
816
|
+
label: "Active",
|
|
817
|
+
emptyState: {
|
|
818
|
+
title: "No delegations",
|
|
819
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
627
823
|
}
|
|
628
824
|
};
|
|
629
825
|
}
|
|
@@ -840,7 +1036,163 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
840
1036
|
// human-readable CSV source of truth only.
|
|
841
1037
|
{ fields: ["status", "updated_at"] },
|
|
842
1038
|
{ fields: ["submitter_id", "status"] }
|
|
843
|
-
]
|
|
1039
|
+
],
|
|
1040
|
+
// Server-declared decision actions (objectui#2678 P2-4). The console's
|
|
1041
|
+
// generic action runtime renders and executes these wherever this object is
|
|
1042
|
+
// surfaced — the approvals inbox included — so new decision capabilities
|
|
1043
|
+
// (and their params) ship as metadata, not as hand-written buttons. Each
|
|
1044
|
+
// targets the existing approvals REST route; `{id}` resolves from the row
|
|
1045
|
+
// and `actorId` defaults to the caller server-side. The service remains the
|
|
1046
|
+
// authority on who may act (pending-approver check) — `visible` only trims
|
|
1047
|
+
// the obvious non-pending case.
|
|
1048
|
+
actions: [
|
|
1049
|
+
{
|
|
1050
|
+
name: "approval_approve",
|
|
1051
|
+
label: "Approve",
|
|
1052
|
+
icon: "check-circle",
|
|
1053
|
+
type: "api",
|
|
1054
|
+
method: "POST",
|
|
1055
|
+
target: "/api/v1/approvals/requests/{id}/approve",
|
|
1056
|
+
params: [
|
|
1057
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1058
|
+
],
|
|
1059
|
+
visible: 'record.status == "pending"',
|
|
1060
|
+
locations: ["record_section", "list_item"],
|
|
1061
|
+
successMessage: "Approved.",
|
|
1062
|
+
refreshAfter: true
|
|
1063
|
+
},
|
|
1064
|
+
{
|
|
1065
|
+
name: "approval_reject",
|
|
1066
|
+
label: "Reject",
|
|
1067
|
+
icon: "x-circle",
|
|
1068
|
+
type: "api",
|
|
1069
|
+
method: "POST",
|
|
1070
|
+
target: "/api/v1/approvals/requests/{id}/reject",
|
|
1071
|
+
params: [
|
|
1072
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1073
|
+
],
|
|
1074
|
+
visible: 'record.status == "pending"',
|
|
1075
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1076
|
+
locations: ["record_section", "list_item"],
|
|
1077
|
+
successMessage: "Rejected.",
|
|
1078
|
+
refreshAfter: true
|
|
1079
|
+
},
|
|
1080
|
+
{
|
|
1081
|
+
name: "approval_reassign",
|
|
1082
|
+
label: "Reassign",
|
|
1083
|
+
icon: "arrow-right-left",
|
|
1084
|
+
type: "api",
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
target: "/api/v1/approvals/requests/{id}/reassign",
|
|
1087
|
+
params: [
|
|
1088
|
+
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
|
|
1089
|
+
// the console resolves its lookup config (`reference_to: sys_user`) so the
|
|
1090
|
+
// dialog renders a real user picker, while `name: 'to'` overrides the
|
|
1091
|
+
// request-body key to the `to` the reassign route expects. This is a
|
|
1092
|
+
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
|
|
1093
|
+
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1094
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1095
|
+
],
|
|
1096
|
+
visible: 'record.status == "pending"',
|
|
1097
|
+
locations: ["record_section"],
|
|
1098
|
+
successMessage: "Reassigned.",
|
|
1099
|
+
refreshAfter: true
|
|
1100
|
+
},
|
|
1101
|
+
// ── Approver secondary decisions ────────────────────────────────
|
|
1102
|
+
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
1103
|
+
// actions on a pending request; the service is the authority on who may act,
|
|
1104
|
+
// so `visible` only trims the non-pending case (matching approve/reject).
|
|
1105
|
+
{
|
|
1106
|
+
name: "approval_send_back",
|
|
1107
|
+
label: "Send back",
|
|
1108
|
+
icon: "corner-up-left",
|
|
1109
|
+
type: "api",
|
|
1110
|
+
method: "POST",
|
|
1111
|
+
target: "/api/v1/approvals/requests/{id}/revise",
|
|
1112
|
+
params: [
|
|
1113
|
+
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1114
|
+
],
|
|
1115
|
+
visible: 'record.status == "pending"',
|
|
1116
|
+
locations: ["record_section"],
|
|
1117
|
+
successMessage: "Sent back for revision.",
|
|
1118
|
+
refreshAfter: true
|
|
1119
|
+
},
|
|
1120
|
+
{
|
|
1121
|
+
name: "approval_request_info",
|
|
1122
|
+
label: "Request info",
|
|
1123
|
+
icon: "help-circle",
|
|
1124
|
+
type: "api",
|
|
1125
|
+
method: "POST",
|
|
1126
|
+
target: "/api/v1/approvals/requests/{id}/request-info",
|
|
1127
|
+
params: [
|
|
1128
|
+
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1129
|
+
],
|
|
1130
|
+
visible: 'record.status == "pending"',
|
|
1131
|
+
locations: ["record_section"],
|
|
1132
|
+
successMessage: "Information requested.",
|
|
1133
|
+
refreshAfter: true
|
|
1134
|
+
},
|
|
1135
|
+
// ── Submitter continuity actions ────────────────────────────────
|
|
1136
|
+
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1137
|
+
// submitter's own levers, so `visible` gates on `submitter_id == ctx.user.id`
|
|
1138
|
+
// — the current user is exposed via the console's predicate scope. The
|
|
1139
|
+
// service re-checks ownership; the predicate keeps a non-submitter from ever
|
|
1140
|
+
// seeing a button they cannot use.
|
|
1141
|
+
{
|
|
1142
|
+
name: "approval_remind",
|
|
1143
|
+
label: "Send reminder",
|
|
1144
|
+
icon: "bell-ring",
|
|
1145
|
+
type: "api",
|
|
1146
|
+
method: "POST",
|
|
1147
|
+
target: "/api/v1/approvals/requests/{id}/remind",
|
|
1148
|
+
params: [
|
|
1149
|
+
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1150
|
+
],
|
|
1151
|
+
visible: 'record.status == "pending" && record.submitter_id == ctx.user.id',
|
|
1152
|
+
locations: ["record_section"],
|
|
1153
|
+
successMessage: "Reminder sent.",
|
|
1154
|
+
refreshAfter: true
|
|
1155
|
+
},
|
|
1156
|
+
{
|
|
1157
|
+
name: "approval_recall",
|
|
1158
|
+
label: "Recall",
|
|
1159
|
+
icon: "undo-2",
|
|
1160
|
+
type: "api",
|
|
1161
|
+
method: "POST",
|
|
1162
|
+
target: "/api/v1/approvals/requests/{id}/recall",
|
|
1163
|
+
params: [
|
|
1164
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1165
|
+
],
|
|
1166
|
+
// Recall applies while the request is live for the submitter — pending
|
|
1167
|
+
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1168
|
+
visible: '(record.status == "pending" || record.status == "returned") && record.submitter_id == ctx.user.id',
|
|
1169
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1170
|
+
locations: ["record_section"],
|
|
1171
|
+
successMessage: "Recalled.",
|
|
1172
|
+
refreshAfter: true
|
|
1173
|
+
},
|
|
1174
|
+
{
|
|
1175
|
+
name: "approval_resubmit",
|
|
1176
|
+
label: "Resubmit",
|
|
1177
|
+
icon: "refresh-cw",
|
|
1178
|
+
type: "api",
|
|
1179
|
+
method: "POST",
|
|
1180
|
+
target: "/api/v1/approvals/requests/{id}/resubmit",
|
|
1181
|
+
params: [
|
|
1182
|
+
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1183
|
+
],
|
|
1184
|
+
visible: 'record.status == "returned" && record.submitter_id == ctx.user.id',
|
|
1185
|
+
locations: ["record_section"],
|
|
1186
|
+
successMessage: "Resubmitted.",
|
|
1187
|
+
refreshAfter: true
|
|
1188
|
+
}
|
|
1189
|
+
],
|
|
1190
|
+
enable: {
|
|
1191
|
+
// [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
|
|
1192
|
+
// (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
|
|
1193
|
+
// generic CRUD. Reads stay open.
|
|
1194
|
+
apiMethods: ["get", "list"]
|
|
1195
|
+
}
|
|
844
1196
|
});
|
|
845
1197
|
|
|
846
1198
|
// src/sys-approval-action.object.ts
|
|
@@ -918,8 +1270,9 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
918
1270
|
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
|
|
919
1271
|
// remind / request_info / comment are thread interactions — they never
|
|
920
1272
|
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
|
|
921
|
-
// revision and the later resubmission.
|
|
922
|
-
|
|
1273
|
+
// revision and the later resubmission. ooo_substitute (#1322 M1) is a
|
|
1274
|
+
// system-recorded reroute of an out-of-office approver — no flow movement.
|
|
1275
|
+
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
923
1276
|
{
|
|
924
1277
|
label: "Action",
|
|
925
1278
|
required: true,
|
|
@@ -932,6 +1285,13 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
932
1285
|
group: "Action"
|
|
933
1286
|
}),
|
|
934
1287
|
comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1288
|
+
attachments: Field2.file({
|
|
1289
|
+
label: "Attachments",
|
|
1290
|
+
required: false,
|
|
1291
|
+
multiple: true,
|
|
1292
|
+
group: "Action",
|
|
1293
|
+
description: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
1294
|
+
}),
|
|
935
1295
|
created_at: Field2.datetime({
|
|
936
1296
|
label: "Created At",
|
|
937
1297
|
required: true,
|
|
@@ -943,7 +1303,12 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
943
1303
|
indexes: [
|
|
944
1304
|
{ fields: ["request_id", "created_at"] },
|
|
945
1305
|
{ fields: ["request_id", "step_index", "action"] }
|
|
946
|
-
]
|
|
1306
|
+
],
|
|
1307
|
+
enable: {
|
|
1308
|
+
// [ADR-0103] Engine-owned append-only decision log: appended by the approval
|
|
1309
|
+
// engine (SYSTEM_CTX). Reads stay open.
|
|
1310
|
+
apiMethods: ["get", "list"]
|
|
1311
|
+
}
|
|
947
1312
|
});
|
|
948
1313
|
|
|
949
1314
|
// src/sys-approval-approver.object.ts
|
|
@@ -994,20 +1359,128 @@ var SysApprovalApprover = ObjectSchema3.create({
|
|
|
994
1359
|
{ fields: ["approver", "organization_id"] },
|
|
995
1360
|
// Sync path: rewrite all rows of one request on each approver-set change.
|
|
996
1361
|
{ fields: ["request_id"] }
|
|
997
|
-
]
|
|
1362
|
+
],
|
|
1363
|
+
enable: {
|
|
1364
|
+
// [ADR-0103] Engine-owned: approver rows are rewritten by the approval
|
|
1365
|
+
// engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
|
|
1366
|
+
apiMethods: ["get", "list"]
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
// src/sys-approval-delegation.object.ts
|
|
1371
|
+
import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
|
|
1372
|
+
var SysApprovalDelegation = ObjectSchema4.create({
|
|
1373
|
+
name: "sys_approval_delegation",
|
|
1374
|
+
label: "Approval Delegation",
|
|
1375
|
+
pluralLabel: "Approval Delegations",
|
|
1376
|
+
icon: "user-clock",
|
|
1377
|
+
isSystem: true,
|
|
1378
|
+
managedBy: "system",
|
|
1379
|
+
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
|
|
1380
|
+
// authors their own out-of-office delegation. Affordance only (matches the
|
|
1381
|
+
// full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
|
|
1382
|
+
// keeps the system write guard from rejecting the self-service write.
|
|
1383
|
+
userActions: { create: true, edit: true, delete: true },
|
|
1384
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1385
|
+
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1386
|
+
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
1387
|
+
listViews: {
|
|
1388
|
+
active: {
|
|
1389
|
+
type: "grid",
|
|
1390
|
+
name: "active",
|
|
1391
|
+
label: "Active",
|
|
1392
|
+
data: { provider: "object", object: "sys_approval_delegation" },
|
|
1393
|
+
columns: ["delegator_id", "delegate_id", "valid_from", "valid_until", "reason"],
|
|
1394
|
+
sort: [{ field: "valid_until", order: "asc" }],
|
|
1395
|
+
pagination: { pageSize: 50 },
|
|
1396
|
+
emptyState: {
|
|
1397
|
+
title: "No delegations",
|
|
1398
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
},
|
|
1402
|
+
fields: {
|
|
1403
|
+
id: Field4.text({ label: "Delegation ID", required: true, readonly: true, group: "System" }),
|
|
1404
|
+
delegator_id: Field4.lookup("sys_user", {
|
|
1405
|
+
label: "Delegator",
|
|
1406
|
+
required: true,
|
|
1407
|
+
group: "Delegation",
|
|
1408
|
+
description: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
1409
|
+
}),
|
|
1410
|
+
delegate_id: Field4.lookup("sys_user", {
|
|
1411
|
+
label: "Delegate",
|
|
1412
|
+
required: true,
|
|
1413
|
+
group: "Delegation",
|
|
1414
|
+
description: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
1415
|
+
}),
|
|
1416
|
+
valid_from: Field4.datetime({
|
|
1417
|
+
label: "Valid From",
|
|
1418
|
+
required: false,
|
|
1419
|
+
group: "Delegation",
|
|
1420
|
+
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."
|
|
1421
|
+
}),
|
|
1422
|
+
valid_until: Field4.datetime({
|
|
1423
|
+
label: "Valid Until",
|
|
1424
|
+
required: false,
|
|
1425
|
+
group: "Delegation",
|
|
1426
|
+
description: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
1427
|
+
}),
|
|
1428
|
+
reason: Field4.text({
|
|
1429
|
+
label: "Reason",
|
|
1430
|
+
required: false,
|
|
1431
|
+
maxLength: 500,
|
|
1432
|
+
group: "Delegation",
|
|
1433
|
+
description: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
1434
|
+
}),
|
|
1435
|
+
organization_id: Field4.lookup("sys_organization", {
|
|
1436
|
+
label: "Organization",
|
|
1437
|
+
required: false,
|
|
1438
|
+
group: "System",
|
|
1439
|
+
description: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
1440
|
+
}),
|
|
1441
|
+
created_at: Field4.datetime({
|
|
1442
|
+
label: "Created At",
|
|
1443
|
+
defaultValue: "NOW()",
|
|
1444
|
+
readonly: true,
|
|
1445
|
+
group: "System"
|
|
1446
|
+
}),
|
|
1447
|
+
updated_at: Field4.datetime({
|
|
1448
|
+
label: "Updated At",
|
|
1449
|
+
defaultValue: "NOW()",
|
|
1450
|
+
readonly: true,
|
|
1451
|
+
group: "System"
|
|
1452
|
+
})
|
|
1453
|
+
},
|
|
1454
|
+
indexes: [
|
|
1455
|
+
// Resolution-time lookup: "active delegations for this delegator".
|
|
1456
|
+
{ fields: ["delegator_id", "organization_id"] },
|
|
1457
|
+
{ fields: ["delegate_id"] },
|
|
1458
|
+
{ fields: ["valid_until"] }
|
|
1459
|
+
],
|
|
1460
|
+
enable: {
|
|
1461
|
+
trackHistory: true,
|
|
1462
|
+
searchable: true,
|
|
1463
|
+
apiEnabled: true,
|
|
1464
|
+
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
1465
|
+
trash: true,
|
|
1466
|
+
mru: false
|
|
1467
|
+
}
|
|
998
1468
|
});
|
|
999
1469
|
|
|
1000
1470
|
// src/approval-service.ts
|
|
1001
1471
|
import { createHash, randomBytes } from "crypto";
|
|
1002
1472
|
import {
|
|
1003
|
-
APPROVAL_BRANCH_LABELS
|
|
1473
|
+
APPROVAL_BRANCH_LABELS,
|
|
1474
|
+
canonicalApproverType
|
|
1004
1475
|
} from "@objectstack/spec/automation";
|
|
1476
|
+
import { isGrantActive } from "@objectstack/core";
|
|
1005
1477
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1006
1478
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1007
1479
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1008
1480
|
var SLA_ACTOR_ID = "system:sla";
|
|
1009
1481
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1010
1482
|
var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
|
|
1483
|
+
var OOO_MAX_CHAIN = 8;
|
|
1011
1484
|
function uid(prefix) {
|
|
1012
1485
|
const g = globalThis;
|
|
1013
1486
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1080,6 +1553,10 @@ function rowFromAction(row) {
|
|
|
1080
1553
|
action: row.action,
|
|
1081
1554
|
actor_id: row.actor_id ?? void 0,
|
|
1082
1555
|
comment: row.comment ?? void 0,
|
|
1556
|
+
// Decision attachments (#3266). The column shipped in #3268 but this
|
|
1557
|
+
// contract mapping didn't — the raw engine row carried the fileIds while
|
|
1558
|
+
// every consumer of listActions saw none (caught by browser verification).
|
|
1559
|
+
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
|
|
1083
1560
|
created_at: row.created_at ?? void 0
|
|
1084
1561
|
};
|
|
1085
1562
|
}
|
|
@@ -1104,8 +1581,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1104
1581
|
async notify(input) {
|
|
1105
1582
|
const audience = input.audience.filter((a) => a && !a.includes(":"));
|
|
1106
1583
|
if (!this.messaging || !audience.length) return 0;
|
|
1584
|
+
let payload = input.payload;
|
|
1585
|
+
if (payload?.actionUrl === "/system/approvals" && input.source?.object === "sys_approval_request" && input.source.id) {
|
|
1586
|
+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
|
|
1587
|
+
}
|
|
1107
1588
|
try {
|
|
1108
|
-
await this.messaging.emit({ severity: "info", ...input, audience });
|
|
1589
|
+
await this.messaging.emit({ severity: "info", ...input, payload, audience });
|
|
1109
1590
|
return audience.length;
|
|
1110
1591
|
} catch (err) {
|
|
1111
1592
|
this.logger?.warn?.("[approvals] notification failed", {
|
|
@@ -1130,10 +1611,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1130
1611
|
}
|
|
1131
1612
|
/**
|
|
1132
1613
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1133
|
-
* graph tables for `team:` / `department:` / `position:` /
|
|
1134
|
-
* `manager:` approver types. Falls back to a
|
|
1135
|
-
* (`type:value`) when graph lookups produce nothing — so
|
|
1136
|
-
* and flows that rely on substring matching keep working.
|
|
1614
|
+
* graph tables for `team:` / `department:` / `position:` /
|
|
1615
|
+
* `org_membership_level:` / `manager:` approver types. Falls back to a
|
|
1616
|
+
* prefixed literal (`type:value`) when graph lookups produce nothing — so
|
|
1617
|
+
* existing fixtures and flows that rely on substring matching keep working.
|
|
1137
1618
|
*
|
|
1138
1619
|
* **Graph semantics:**
|
|
1139
1620
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
@@ -1141,66 +1622,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1141
1622
|
* → members of every descendant via `sys_business_unit_member`
|
|
1142
1623
|
* - `position` → holders via `sys_user_position` ∪ `sys_member.role`
|
|
1143
1624
|
* transition source (ADR-0090 D3 / ADR-0057 D4)
|
|
1144
|
-
* - `
|
|
1625
|
+
* - `org_membership_level`
|
|
1626
|
+
* → users with `sys_member.role = value` in tenant — the
|
|
1145
1627
|
* better-auth MEMBERSHIP TIER (owner/admin/member), not a
|
|
1146
1628
|
* position; author `position` for org positions
|
|
1147
1629
|
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
1148
1630
|
* - `field` → literal user id stored in `record[value]`
|
|
1149
1631
|
* - `user` → literal value
|
|
1632
|
+
*
|
|
1633
|
+
* `role` is accepted as the deprecated spelling of `org_membership_level`
|
|
1634
|
+
* (ADR-0090 D3) for one window: it resolves identically and logs a warning.
|
|
1635
|
+
*
|
|
1636
|
+
* **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
|
|
1637
|
+
* resolve to a specific person (`user` / `field` / `manager`) — are passed
|
|
1638
|
+
* through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
|
|
1639
|
+
* an active delegate when the resolved user has declared OOO. Group/graph
|
|
1640
|
+
* approvers (`team` / `department` / `position` / `org_membership_level`) are
|
|
1641
|
+
* left untouched: a group still has its other members, and position-routed
|
|
1642
|
+
* leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
|
|
1643
|
+
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1150
1644
|
*/
|
|
1151
|
-
async expandApprovers(step, record, organizationId) {
|
|
1645
|
+
async expandApprovers(step, record, organizationId, opts) {
|
|
1646
|
+
var _a;
|
|
1152
1647
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1648
|
+
const now = opts?.now ?? this.clock.now().getTime();
|
|
1153
1649
|
const out = [];
|
|
1154
|
-
|
|
1650
|
+
const specs = step.approvers;
|
|
1651
|
+
for (let idx = 0; idx < specs.length; idx++) {
|
|
1652
|
+
const a = specs[idx];
|
|
1155
1653
|
if (!a) continue;
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1654
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1655
|
+
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
1656
|
+
for (const u of ids) {
|
|
1657
|
+
if (!u) continue;
|
|
1658
|
+
out.push(u);
|
|
1659
|
+
if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
|
|
1159
1660
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1661
|
+
}
|
|
1662
|
+
return out.filter(Boolean);
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Resolve ONE approver spec to concrete approver identities, applying OOO
|
|
1666
|
+
* substitution (#1322) to individually-routed types. Extracted from
|
|
1667
|
+
* {@link ApprovalService.expandApprovers} so the caller can tag each spec's
|
|
1668
|
+
* resolved ids with a group (#3266) without duplicating the resolution logic.
|
|
1669
|
+
* Returns the `type:value` literal as a single-element fallback when a graph
|
|
1670
|
+
* lookup yields nothing — same behaviour as before the extraction.
|
|
1671
|
+
*/
|
|
1672
|
+
async resolveApproverSpec(a, record, organizationId, now, substitutions) {
|
|
1673
|
+
const type = canonicalApproverType(String(a.type));
|
|
1674
|
+
if (type !== a.type) {
|
|
1675
|
+
this.logger?.warn?.(
|
|
1676
|
+
`[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) \u2014 author '${type}' instead`,
|
|
1677
|
+
{ deprecated: a.type, canonical: type }
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1680
|
+
if (type === "user") {
|
|
1681
|
+
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1682
|
+
}
|
|
1683
|
+
if (type === "field" && record) {
|
|
1684
|
+
return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
|
|
1685
|
+
}
|
|
1686
|
+
try {
|
|
1687
|
+
if (type === "team") {
|
|
1688
|
+
const users = await this.expandTeamUsers(String(a.value));
|
|
1689
|
+
if (users.length) return users;
|
|
1690
|
+
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1691
|
+
const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
|
|
1692
|
+
if (users.length) return users;
|
|
1693
|
+
} else if (type === "position") {
|
|
1694
|
+
const users = await this.expandPositionUsers(String(a.value), organizationId);
|
|
1695
|
+
if (users.length) return users;
|
|
1696
|
+
} else if (type === "org_membership_level") {
|
|
1697
|
+
const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
|
|
1698
|
+
if (users.length) return users;
|
|
1699
|
+
} else if (type === "manager" && record) {
|
|
1700
|
+
const subject = record[a.value] ?? record.owner_id;
|
|
1701
|
+
if (subject) {
|
|
1702
|
+
const mgr = await this.lookupManager(String(subject));
|
|
1703
|
+
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
1198
1704
|
}
|
|
1199
|
-
} catch {
|
|
1200
1705
|
}
|
|
1201
|
-
|
|
1706
|
+
} catch {
|
|
1202
1707
|
}
|
|
1203
|
-
return
|
|
1708
|
+
return [`${a.type}:${a.value}`];
|
|
1204
1709
|
}
|
|
1205
1710
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1206
1711
|
async expandTeamUsers(teamId) {
|
|
@@ -1292,13 +1797,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1292
1797
|
}
|
|
1293
1798
|
} catch {
|
|
1294
1799
|
}
|
|
1295
|
-
for (const uid2 of await this.
|
|
1800
|
+
for (const uid2 of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid2);
|
|
1296
1801
|
return Array.from(users);
|
|
1297
1802
|
}
|
|
1298
|
-
/**
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1803
|
+
/**
|
|
1804
|
+
* better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
|
|
1805
|
+
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
1806
|
+
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
1807
|
+
* and stays, the platform-facing word does not.
|
|
1808
|
+
*/
|
|
1809
|
+
async expandMembershipTierUsers(tier, organizationId) {
|
|
1810
|
+
if (!tier) return [];
|
|
1811
|
+
const filter = { role: tier };
|
|
1302
1812
|
if (organizationId) filter.organization_id = organizationId;
|
|
1303
1813
|
let rows = [];
|
|
1304
1814
|
try {
|
|
@@ -1322,6 +1832,62 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1322
1832
|
return null;
|
|
1323
1833
|
}
|
|
1324
1834
|
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
1837
|
+
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
1838
|
+
* slot should actually go to — the delegate acts under their own identity, so
|
|
1839
|
+
* no impersonation is involved. Returns `[userId]` unchanged when there is no
|
|
1840
|
+
* active delegation. Each hop is appended to `collector` (when supplied) so
|
|
1841
|
+
* the caller can audit + notify (M4).
|
|
1842
|
+
*
|
|
1843
|
+
* The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
|
|
1844
|
+
* stops on a self-reference or a cycle, so a mis-declared loop degrades to the
|
|
1845
|
+
* last reachable delegate rather than hanging.
|
|
1846
|
+
*/
|
|
1847
|
+
async applyOooDelegation(userId, now, organizationId, collector) {
|
|
1848
|
+
const start = String(userId ?? "").trim();
|
|
1849
|
+
if (!start) return [];
|
|
1850
|
+
let current = start;
|
|
1851
|
+
const visited = /* @__PURE__ */ new Set([current]);
|
|
1852
|
+
for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
|
|
1853
|
+
const del = await this.lookupActiveDelegation(current, now, organizationId);
|
|
1854
|
+
if (!del) break;
|
|
1855
|
+
const to = String(del.delegate_id ?? "").trim();
|
|
1856
|
+
if (!to || to === current || visited.has(to)) break;
|
|
1857
|
+
collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
|
|
1858
|
+
visited.add(to);
|
|
1859
|
+
current = to;
|
|
1860
|
+
}
|
|
1861
|
+
return [current];
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* The active OOO delegation for a delegator at `now`, or null. Validity is the
|
|
1865
|
+
* shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
|
|
1866
|
+
* resolution time — never by a background job. When several rows are active,
|
|
1867
|
+
* the one expiring soonest wins (the most specific coverage window).
|
|
1868
|
+
*/
|
|
1869
|
+
async lookupActiveDelegation(delegatorId, now, organizationId) {
|
|
1870
|
+
if (!delegatorId) return null;
|
|
1871
|
+
let rows = [];
|
|
1872
|
+
try {
|
|
1873
|
+
rows = await this.engine.find("sys_approval_delegation", {
|
|
1874
|
+
filter: { delegator_id: delegatorId },
|
|
1875
|
+
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1876
|
+
limit: 50,
|
|
1877
|
+
context: SYSTEM_CTX
|
|
1878
|
+
});
|
|
1879
|
+
} catch {
|
|
1880
|
+
return null;
|
|
1881
|
+
}
|
|
1882
|
+
const active = (rows ?? []).filter((r) => isGrantActive(r, now) && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
|
|
1883
|
+
if (!active.length) return null;
|
|
1884
|
+
active.sort((a, b) => {
|
|
1885
|
+
const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1886
|
+
const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1887
|
+
return au - bu;
|
|
1888
|
+
});
|
|
1889
|
+
return active[0];
|
|
1890
|
+
}
|
|
1325
1891
|
/** Mirror a request status onto a business-object field, if configured. */
|
|
1326
1892
|
async mirrorStatusField(object, recordId, field, status) {
|
|
1327
1893
|
try {
|
|
@@ -1355,13 +1921,24 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1355
1921
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
1356
1922
|
}
|
|
1357
1923
|
const ctxOrg = context?.organizationId ?? context?.tenantId ?? input.organizationId ?? null;
|
|
1358
|
-
const
|
|
1359
|
-
const
|
|
1924
|
+
const nowDate = this.clock.now();
|
|
1925
|
+
const substitutions = [];
|
|
1926
|
+
const groups = {};
|
|
1927
|
+
const approvers = await this.expandApprovers(
|
|
1928
|
+
{ approvers: input.config.approvers },
|
|
1929
|
+
input.record,
|
|
1930
|
+
ctxOrg,
|
|
1931
|
+
{ now: nowDate.getTime(), substitutions, groups }
|
|
1932
|
+
);
|
|
1933
|
+
const now = nowDate.toISOString();
|
|
1360
1934
|
const id = uid("areq");
|
|
1361
1935
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1362
1936
|
const configSnapshot = { ...input.config };
|
|
1363
1937
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1364
1938
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1939
|
+
if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
|
|
1940
|
+
configSnapshot.__approverGroups = groups;
|
|
1941
|
+
}
|
|
1365
1942
|
try {
|
|
1366
1943
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1367
1944
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
@@ -1403,17 +1980,83 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1403
1980
|
comment: null,
|
|
1404
1981
|
created_at: now
|
|
1405
1982
|
}, { context: SYSTEM_CTX });
|
|
1983
|
+
for (const sub of substitutions) {
|
|
1984
|
+
await this.engine.insert("sys_approval_action", {
|
|
1985
|
+
id: uid("aact"),
|
|
1986
|
+
request_id: id,
|
|
1987
|
+
organization_id: ctxOrg,
|
|
1988
|
+
step_name: input.nodeId,
|
|
1989
|
+
step_index: 0,
|
|
1990
|
+
action: "ooo_substitute",
|
|
1991
|
+
actor_id: null,
|
|
1992
|
+
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
1993
|
+
created_at: now
|
|
1994
|
+
}, { context: SYSTEM_CTX });
|
|
1995
|
+
await this.notify({
|
|
1996
|
+
topic: "approval.ooo_substituted",
|
|
1997
|
+
audience: [sub.to],
|
|
1998
|
+
source: { object: "sys_approval_request", id },
|
|
1999
|
+
dedupKey: `approval-ooo-${id}-${sub.to}`,
|
|
2000
|
+
payload: {
|
|
2001
|
+
title: "Approval routed to you (out-of-office cover)",
|
|
2002
|
+
message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
|
|
2003
|
+
actionUrl: "/system/approvals"
|
|
2004
|
+
}
|
|
2005
|
+
});
|
|
2006
|
+
await this.notify({
|
|
2007
|
+
topic: "approval.ooo_skipped",
|
|
2008
|
+
audience: [sub.from],
|
|
2009
|
+
source: { object: "sys_approval_request", id },
|
|
2010
|
+
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
|
|
2011
|
+
payload: {
|
|
2012
|
+
title: "Approval routed to your delegate",
|
|
2013
|
+
message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
|
|
2014
|
+
actionUrl: "/system/approvals"
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
}
|
|
1406
2018
|
if (input.config.approvalStatusField) {
|
|
1407
2019
|
await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
|
|
1408
2020
|
}
|
|
1409
2021
|
return rowFromRequest(row);
|
|
1410
2022
|
}
|
|
1411
2023
|
/**
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
2024
|
+
* True when the approve tally satisfies the node's `behavior` (#3266):
|
|
2025
|
+
* - `unanimous` — every resolved approver approved.
|
|
2026
|
+
* - `quorum` — at least `minApprovals` distinct approvals (default = all).
|
|
2027
|
+
* - `per_group` — every group reached `minApprovals` approvals (default 1).
|
|
2028
|
+
* Thresholds are clamped to the resolvable count / group size, so a mis-set
|
|
2029
|
+
* value can never deadlock a request.
|
|
2030
|
+
*/
|
|
2031
|
+
isApprovalSatisfied(behavior, config, original, groupMap, approved) {
|
|
2032
|
+
if (behavior === "unanimous") {
|
|
2033
|
+
return original.length > 0 && original.every((a) => approved.has(a));
|
|
2034
|
+
}
|
|
2035
|
+
if (behavior === "quorum") {
|
|
2036
|
+
const n = original.length || 1;
|
|
2037
|
+
const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
|
|
2038
|
+
return approved.size >= need;
|
|
2039
|
+
}
|
|
2040
|
+
if (behavior === "per_group") {
|
|
2041
|
+
const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
|
|
2042
|
+
const size = {};
|
|
2043
|
+
for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
2044
|
+
const groups = Object.keys(size);
|
|
2045
|
+
if (!groups.length) return true;
|
|
2046
|
+
const got = {};
|
|
2047
|
+
for (const a of approved) for (const g of groupMap[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
2048
|
+
return groups.every((g) => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
|
|
2049
|
+
}
|
|
2050
|
+
return true;
|
|
2051
|
+
}
|
|
2052
|
+
/**
|
|
2053
|
+
* Record a decision on a node-driven request. Honours the node's `behavior`
|
|
2054
|
+
* (#3266): `first_response` finalizes on the first approval; `unanimous`,
|
|
2055
|
+
* `quorum`, and `per_group` hold the request open until their tally is met
|
|
2056
|
+
* (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
|
|
2057
|
+
* finalizes the node (one veto). When the request finalizes, returns the
|
|
2058
|
+
* suspended run id + node id so the caller (or {@link ApprovalService.decide})
|
|
2059
|
+
* can resume the flow down the matching branch.
|
|
1417
2060
|
*/
|
|
1418
2061
|
async decideNode(requestId, input, context) {
|
|
1419
2062
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -1447,22 +2090,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1447
2090
|
action: input.decision,
|
|
1448
2091
|
actor_id: input.actorId,
|
|
1449
2092
|
comment: input.comment ?? null,
|
|
2093
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
1450
2094
|
created_at: now
|
|
1451
2095
|
}, { context: SYSTEM_CTX });
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
{ approvers: config.approvers },
|
|
1455
|
-
parseJson(raw.payload_json, void 0),
|
|
1456
|
-
org
|
|
1457
|
-
);
|
|
2096
|
+
const behavior = config.behavior ?? "first_response";
|
|
2097
|
+
if (input.decision === "approve" && behavior !== "first_response") {
|
|
1458
2098
|
const acts = await this.engine.find("sys_approval_action", {
|
|
1459
2099
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
1460
|
-
limit:
|
|
2100
|
+
limit: 1e3,
|
|
1461
2101
|
context: SYSTEM_CTX
|
|
1462
2102
|
});
|
|
1463
2103
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
1464
|
-
const
|
|
1465
|
-
|
|
2104
|
+
const snapshotGroups = config.__approverGroups;
|
|
2105
|
+
let original;
|
|
2106
|
+
let groupMap;
|
|
2107
|
+
if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
|
|
2108
|
+
groupMap = snapshotGroups;
|
|
2109
|
+
original = Object.keys(snapshotGroups);
|
|
2110
|
+
} else {
|
|
2111
|
+
original = await this.expandApprovers(
|
|
2112
|
+
{ approvers: config.approvers },
|
|
2113
|
+
parseJson(raw.payload_json, void 0),
|
|
2114
|
+
org
|
|
2115
|
+
);
|
|
2116
|
+
groupMap = {};
|
|
2117
|
+
}
|
|
2118
|
+
if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
|
|
2119
|
+
const stillPending = original.filter((a) => !approved.has(a));
|
|
1466
2120
|
await this.engine.update("sys_approval_request", {
|
|
1467
2121
|
id: requestId,
|
|
1468
2122
|
pending_approvers: stillPending.join(","),
|
|
@@ -1896,10 +2550,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1896
2550
|
comment: input.comment ?? `${from} \u2192 ${to}`,
|
|
1897
2551
|
created_at: now
|
|
1898
2552
|
}, { context: SYSTEM_CTX });
|
|
2553
|
+
let configPatch = {};
|
|
2554
|
+
try {
|
|
2555
|
+
const cfg = parseJson(raw.node_config_json, null);
|
|
2556
|
+
const groups = cfg?.__approverGroups;
|
|
2557
|
+
if (groups && groups[from] && !groups[to]) {
|
|
2558
|
+
groups[to] = groups[from];
|
|
2559
|
+
delete groups[from];
|
|
2560
|
+
configPatch = { node_config_json: JSON.stringify(cfg) };
|
|
2561
|
+
}
|
|
2562
|
+
} catch {
|
|
2563
|
+
}
|
|
1899
2564
|
await this.engine.update("sys_approval_request", {
|
|
1900
2565
|
id: requestId,
|
|
1901
2566
|
pending_approvers: next.join(","),
|
|
1902
|
-
updated_at: now
|
|
2567
|
+
updated_at: now,
|
|
2568
|
+
...configPatch
|
|
1903
2569
|
}, { context: SYSTEM_CTX });
|
|
1904
2570
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
1905
2571
|
await this.notify({
|
|
@@ -2144,6 +2810,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2144
2810
|
action: "comment",
|
|
2145
2811
|
actor_id: input.actorId,
|
|
2146
2812
|
comment: input.comment.trim(),
|
|
2813
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
2147
2814
|
created_at: now
|
|
2148
2815
|
}, { context: SYSTEM_CTX });
|
|
2149
2816
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
@@ -2701,8 +3368,54 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2701
3368
|
const row = rowFromRequest(rows[0]);
|
|
2702
3369
|
await this.enrichRows([row]);
|
|
2703
3370
|
await this.attachFlowSteps(row);
|
|
3371
|
+
await this.attachDecisionProgress(row, rows[0]);
|
|
2704
3372
|
return row;
|
|
2705
3373
|
}
|
|
3374
|
+
/**
|
|
3375
|
+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
|
|
3376
|
+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
|
|
3377
|
+
* for a PENDING request whose behavior aggregates multiple approvals
|
|
3378
|
+
* (`unanimous` / `quorum` / `per_group`), expose
|
|
3379
|
+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
|
|
3380
|
+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
|
|
3381
|
+
* `first_response` requests carry no progress (one approval finalizes).
|
|
3382
|
+
* Display-only and best-effort — errors leave the row untouched.
|
|
3383
|
+
*/
|
|
3384
|
+
async attachDecisionProgress(row, raw) {
|
|
3385
|
+
try {
|
|
3386
|
+
if (row.status !== "pending") return;
|
|
3387
|
+
const cfg = parseJson(raw.node_config_json, void 0);
|
|
3388
|
+
const behavior = cfg?.behavior ?? "first_response";
|
|
3389
|
+
if (behavior !== "unanimous" && behavior !== "quorum" && behavior !== "per_group") return;
|
|
3390
|
+
const acts = await this.engine.find("sys_approval_action", {
|
|
3391
|
+
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3392
|
+
limit: 1e3,
|
|
3393
|
+
context: SYSTEM_CTX
|
|
3394
|
+
});
|
|
3395
|
+
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3396
|
+
const snapshot = cfg?.__approverGroups;
|
|
3397
|
+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...row.pending_approvers ?? []];
|
|
3398
|
+
const total = slate.length || 1;
|
|
3399
|
+
const progress = { behavior, got: approved.size, need: total };
|
|
3400
|
+
if (behavior === "quorum") {
|
|
3401
|
+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
|
|
3402
|
+
} else if (behavior === "per_group" && snapshot) {
|
|
3403
|
+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
|
|
3404
|
+
const size = {};
|
|
3405
|
+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
3406
|
+
const got = {};
|
|
3407
|
+
for (const a of approved) for (const g of snapshot[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
3408
|
+
progress.groups = Object.keys(size).sort().map((g) => {
|
|
3409
|
+
const need = Math.min(perGroupNeed, size[g]);
|
|
3410
|
+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
|
|
3411
|
+
});
|
|
3412
|
+
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3413
|
+
progress.need = progress.groups.length;
|
|
3414
|
+
}
|
|
3415
|
+
row.decision_progress = progress;
|
|
3416
|
+
} catch {
|
|
3417
|
+
}
|
|
3418
|
+
}
|
|
2706
3419
|
/**
|
|
2707
3420
|
* Derive approval-step progress from the owning flow's graph (single-read
|
|
2708
3421
|
* enrichment only — list reads skip it). Walks from the start node
|
|
@@ -2764,8 +3477,8 @@ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
|
2764
3477
|
var ApprovalService = _ApprovalService;
|
|
2765
3478
|
|
|
2766
3479
|
// src/sys-approval-token.object.ts
|
|
2767
|
-
import { ObjectSchema as
|
|
2768
|
-
var SysApprovalToken =
|
|
3480
|
+
import { ObjectSchema as ObjectSchema5, Field as Field5 } from "@objectstack/spec/data";
|
|
3481
|
+
var SysApprovalToken = ObjectSchema5.create({
|
|
2769
3482
|
name: "sys_approval_token",
|
|
2770
3483
|
label: "Approval Action Token",
|
|
2771
3484
|
pluralLabel: "Approval Action Tokens",
|
|
@@ -2777,13 +3490,13 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2777
3490
|
nameField: "id",
|
|
2778
3491
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
2779
3492
|
fields: {
|
|
2780
|
-
id:
|
|
2781
|
-
organization_id:
|
|
3493
|
+
id: Field5.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
|
|
3494
|
+
organization_id: Field5.lookup("sys_organization", {
|
|
2782
3495
|
label: "Organization",
|
|
2783
3496
|
required: false,
|
|
2784
3497
|
group: "System"
|
|
2785
3498
|
}),
|
|
2786
|
-
token_hash:
|
|
3499
|
+
token_hash: Field5.text({
|
|
2787
3500
|
label: "Token Hash",
|
|
2788
3501
|
required: true,
|
|
2789
3502
|
maxLength: 100,
|
|
@@ -2791,20 +3504,20 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2791
3504
|
description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
|
|
2792
3505
|
group: "Token"
|
|
2793
3506
|
}),
|
|
2794
|
-
request_id:
|
|
3507
|
+
request_id: Field5.text({
|
|
2795
3508
|
label: "Request",
|
|
2796
3509
|
required: true,
|
|
2797
3510
|
maxLength: 100,
|
|
2798
3511
|
readonly: true,
|
|
2799
3512
|
group: "Token"
|
|
2800
3513
|
}),
|
|
2801
|
-
action:
|
|
3514
|
+
action: Field5.select(["approve", "reject"], {
|
|
2802
3515
|
label: "Action",
|
|
2803
3516
|
required: true,
|
|
2804
3517
|
readonly: true,
|
|
2805
3518
|
group: "Token"
|
|
2806
3519
|
}),
|
|
2807
|
-
approver_id:
|
|
3520
|
+
approver_id: Field5.text({
|
|
2808
3521
|
label: "Approver",
|
|
2809
3522
|
required: true,
|
|
2810
3523
|
maxLength: 200,
|
|
@@ -2812,18 +3525,18 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2812
3525
|
description: "Identity the token is bound to; the decision is audited as this approver",
|
|
2813
3526
|
group: "Token"
|
|
2814
3527
|
}),
|
|
2815
|
-
expires_at:
|
|
3528
|
+
expires_at: Field5.datetime({
|
|
2816
3529
|
label: "Expires At",
|
|
2817
3530
|
required: true,
|
|
2818
3531
|
readonly: true,
|
|
2819
3532
|
group: "Lifecycle"
|
|
2820
3533
|
}),
|
|
2821
|
-
consumed_at:
|
|
3534
|
+
consumed_at: Field5.datetime({
|
|
2822
3535
|
label: "Consumed At",
|
|
2823
3536
|
required: false,
|
|
2824
3537
|
group: "Lifecycle"
|
|
2825
3538
|
}),
|
|
2826
|
-
created_at:
|
|
3539
|
+
created_at: Field5.datetime({
|
|
2827
3540
|
label: "Created At",
|
|
2828
3541
|
required: true,
|
|
2829
3542
|
defaultValue: "NOW()",
|
|
@@ -2834,7 +3547,12 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2834
3547
|
indexes: [
|
|
2835
3548
|
{ fields: ["token_hash"] },
|
|
2836
3549
|
{ fields: ["request_id"] }
|
|
2837
|
-
]
|
|
3550
|
+
],
|
|
3551
|
+
enable: {
|
|
3552
|
+
// [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
|
|
3553
|
+
// consumed by the approval engine (SYSTEM_CTX), never via the data API.
|
|
3554
|
+
apiMethods: ["get", "list"]
|
|
3555
|
+
}
|
|
2838
3556
|
});
|
|
2839
3557
|
|
|
2840
3558
|
// src/action-link-pages.ts
|
|
@@ -2958,6 +3676,40 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
2958
3676
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
2959
3677
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
2960
3678
|
}
|
|
3679
|
+
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
3680
|
+
function bindDelegationWriteGuard(engine, logger) {
|
|
3681
|
+
const makeGuard = (isInsert) => async (ctx) => {
|
|
3682
|
+
const session = ctx?.session ?? {};
|
|
3683
|
+
if (session.isSystem) return;
|
|
3684
|
+
const roles = session.roles ?? [];
|
|
3685
|
+
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3686
|
+
const userId = session.userId != null ? String(session.userId) : "";
|
|
3687
|
+
const data = ctx?.input?.data;
|
|
3688
|
+
const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
|
|
3689
|
+
const deny = () => {
|
|
3690
|
+
const err = new Error(
|
|
3691
|
+
"FORBIDDEN: you may only manage out-of-office delegations where you are the delegator" + (userId ? ` ('${userId}')` : "")
|
|
3692
|
+
);
|
|
3693
|
+
err.code = "FORBIDDEN";
|
|
3694
|
+
err.statusCode = 403;
|
|
3695
|
+
throw err;
|
|
3696
|
+
};
|
|
3697
|
+
for (const row of rows) {
|
|
3698
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
3699
|
+
const has = Object.prototype.hasOwnProperty.call(row, "delegator_id");
|
|
3700
|
+
const supplied = has ? String(row.delegator_id ?? "") : "";
|
|
3701
|
+
if (isInsert && (!has || supplied === "")) {
|
|
3702
|
+
if (!userId) deny();
|
|
3703
|
+
row.delegator_id = userId;
|
|
3704
|
+
continue;
|
|
3705
|
+
}
|
|
3706
|
+
if (has && supplied !== userId) deny();
|
|
3707
|
+
}
|
|
3708
|
+
};
|
|
3709
|
+
engine.registerHook("beforeInsert", makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3710
|
+
engine.registerHook("beforeUpdate", makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3711
|
+
logger?.info?.("[approvals] delegation write-guard bound");
|
|
3712
|
+
}
|
|
2961
3713
|
function unbindAllHooks(engine) {
|
|
2962
3714
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
2963
3715
|
}
|
|
@@ -3058,7 +3810,7 @@ var ApprovalsServicePlugin = class {
|
|
|
3058
3810
|
scope: "system",
|
|
3059
3811
|
defaultDatasource: "cloud",
|
|
3060
3812
|
namespace: "sys",
|
|
3061
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
|
|
3813
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
|
|
3062
3814
|
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
3063
3815
|
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
3064
3816
|
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
@@ -3069,7 +3821,8 @@ var ApprovalsServicePlugin = class {
|
|
|
3069
3821
|
priority: 100,
|
|
3070
3822
|
items: [
|
|
3071
3823
|
{ id: "nav_approval_requests", type: "object", label: "Requests", objectName: "sys_approval_request", icon: "inbox", requiresObject: "sys_approval_request" },
|
|
3072
|
-
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" }
|
|
3824
|
+
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" },
|
|
3825
|
+
{ id: "nav_approval_delegations", type: "object", label: "Delegations (OOO)", objectName: "sys_approval_delegation", icon: "user-clock", requiresObject: "sys_approval_delegation" }
|
|
3073
3826
|
]
|
|
3074
3827
|
}
|
|
3075
3828
|
]
|
|
@@ -3115,8 +3868,9 @@ var ApprovalsServicePlugin = class {
|
|
|
3115
3868
|
try {
|
|
3116
3869
|
unbindAllHooks(engine);
|
|
3117
3870
|
bindApprovalLockHook(engine, ctx.logger);
|
|
3871
|
+
bindDelegationWriteGuard(engine, ctx.logger);
|
|
3118
3872
|
} catch (err) {
|
|
3119
|
-
ctx.logger.warn?.("[approvals] failed to bind
|
|
3873
|
+
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
3120
3874
|
}
|
|
3121
3875
|
}
|
|
3122
3876
|
ctx.registerService("approvals", this.service);
|
|
@@ -3233,6 +3987,7 @@ export {
|
|
|
3233
3987
|
ApprovalsServicePlugin,
|
|
3234
3988
|
SysApprovalAction,
|
|
3235
3989
|
SysApprovalApprover,
|
|
3990
|
+
SysApprovalDelegation,
|
|
3236
3991
|
SysApprovalRequest,
|
|
3237
3992
|
registerApprovalNode
|
|
3238
3993
|
};
|