@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +168 -0
- package/dist/index.d.mts +3024 -2413
- package/dist/index.d.ts +3024 -2413
- package/dist/index.js +882 -97
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +883 -98
- 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 +464 -1
- package/src/approval-service.ts +425 -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 +7 -1
- package/src/sys-approval-delegation.object.ts +142 -0
- package/src/sys-approval-request.object.test.ts +103 -0
- package/src/sys-approval-request.object.ts +170 -1
- package/src/sys-approval-token.object.ts +7 -1
- 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
|
}
|
|
@@ -659,7 +855,7 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
659
855
|
pluralLabel: "Approval Requests",
|
|
660
856
|
icon: "inbox",
|
|
661
857
|
isSystem: true,
|
|
662
|
-
managedBy: "
|
|
858
|
+
managedBy: "engine-owned",
|
|
663
859
|
description: "Live approval instance tracked per submission",
|
|
664
860
|
displayNameField: "id",
|
|
665
861
|
nameField: "id",
|
|
@@ -840,7 +1036,172 @@ 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; `visible` gates on the server-computed
|
|
1047
|
+
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1048
|
+
// (the caller is a current pending approver — same check the service
|
|
1049
|
+
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1050
|
+
// submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
|
|
1051
|
+
// getRequest/listRequests; where it is absent the predicate fails closed.
|
|
1052
|
+
actions: [
|
|
1053
|
+
{
|
|
1054
|
+
name: "approval_approve",
|
|
1055
|
+
label: "Approve",
|
|
1056
|
+
icon: "check-circle",
|
|
1057
|
+
type: "api",
|
|
1058
|
+
method: "POST",
|
|
1059
|
+
target: "/api/v1/approvals/requests/{id}/approve",
|
|
1060
|
+
params: [
|
|
1061
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1062
|
+
// Decision attachments (#3266). The console renders `type:'file'` params
|
|
1063
|
+
// through the shared upload widget and POSTs the resolved `attachments:
|
|
1064
|
+
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1065
|
+
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1066
|
+
],
|
|
1067
|
+
visible: "record.viewer.can_act",
|
|
1068
|
+
locations: ["record_section", "list_item"],
|
|
1069
|
+
successMessage: "Approved.",
|
|
1070
|
+
refreshAfter: true
|
|
1071
|
+
},
|
|
1072
|
+
{
|
|
1073
|
+
name: "approval_reject",
|
|
1074
|
+
label: "Reject",
|
|
1075
|
+
icon: "x-circle",
|
|
1076
|
+
type: "api",
|
|
1077
|
+
method: "POST",
|
|
1078
|
+
target: "/api/v1/approvals/requests/{id}/reject",
|
|
1079
|
+
params: [
|
|
1080
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1081
|
+
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1082
|
+
],
|
|
1083
|
+
visible: "record.viewer.can_act",
|
|
1084
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1085
|
+
locations: ["record_section", "list_item"],
|
|
1086
|
+
successMessage: "Rejected.",
|
|
1087
|
+
refreshAfter: true
|
|
1088
|
+
},
|
|
1089
|
+
{
|
|
1090
|
+
name: "approval_reassign",
|
|
1091
|
+
label: "Reassign",
|
|
1092
|
+
icon: "arrow-right-left",
|
|
1093
|
+
type: "api",
|
|
1094
|
+
method: "POST",
|
|
1095
|
+
target: "/api/v1/approvals/requests/{id}/reassign",
|
|
1096
|
+
params: [
|
|
1097
|
+
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
|
|
1098
|
+
// the console resolves its lookup config (`reference_to: sys_user`) so the
|
|
1099
|
+
// dialog renders a real user picker, while `name: 'to'` overrides the
|
|
1100
|
+
// request-body key to the `to` the reassign route expects. This is a
|
|
1101
|
+
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
|
|
1102
|
+
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1103
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1104
|
+
],
|
|
1105
|
+
visible: "record.viewer.can_act",
|
|
1106
|
+
locations: ["record_section"],
|
|
1107
|
+
successMessage: "Reassigned.",
|
|
1108
|
+
refreshAfter: true
|
|
1109
|
+
},
|
|
1110
|
+
// ── Approver secondary decisions ────────────────────────────────
|
|
1111
|
+
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
1112
|
+
// actions, so `visible` gates on `record.viewer.can_act` (a current pending
|
|
1113
|
+
// approver) — same as approve/reject. The service stays the authority.
|
|
1114
|
+
{
|
|
1115
|
+
name: "approval_send_back",
|
|
1116
|
+
label: "Send back",
|
|
1117
|
+
icon: "corner-up-left",
|
|
1118
|
+
type: "api",
|
|
1119
|
+
method: "POST",
|
|
1120
|
+
target: "/api/v1/approvals/requests/{id}/revise",
|
|
1121
|
+
params: [
|
|
1122
|
+
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1123
|
+
],
|
|
1124
|
+
visible: "record.viewer.can_act",
|
|
1125
|
+
locations: ["record_section"],
|
|
1126
|
+
successMessage: "Sent back for revision.",
|
|
1127
|
+
refreshAfter: true
|
|
1128
|
+
},
|
|
1129
|
+
{
|
|
1130
|
+
name: "approval_request_info",
|
|
1131
|
+
label: "Request info",
|
|
1132
|
+
icon: "help-circle",
|
|
1133
|
+
type: "api",
|
|
1134
|
+
method: "POST",
|
|
1135
|
+
target: "/api/v1/approvals/requests/{id}/request-info",
|
|
1136
|
+
params: [
|
|
1137
|
+
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1138
|
+
],
|
|
1139
|
+
visible: "record.viewer.can_act",
|
|
1140
|
+
locations: ["record_section"],
|
|
1141
|
+
successMessage: "Information requested.",
|
|
1142
|
+
refreshAfter: true
|
|
1143
|
+
},
|
|
1144
|
+
// ── Submitter continuity actions ────────────────────────────────
|
|
1145
|
+
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1146
|
+
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
1147
|
+
// (server-computed on the current viewer). The service re-checks ownership;
|
|
1148
|
+
// the predicate keeps a non-submitter from ever seeing a button they cannot
|
|
1149
|
+
// use.
|
|
1150
|
+
{
|
|
1151
|
+
name: "approval_remind",
|
|
1152
|
+
label: "Send reminder",
|
|
1153
|
+
icon: "bell-ring",
|
|
1154
|
+
type: "api",
|
|
1155
|
+
method: "POST",
|
|
1156
|
+
target: "/api/v1/approvals/requests/{id}/remind",
|
|
1157
|
+
params: [
|
|
1158
|
+
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1159
|
+
],
|
|
1160
|
+
visible: 'record.status == "pending" && record.viewer.is_submitter',
|
|
1161
|
+
locations: ["record_section"],
|
|
1162
|
+
successMessage: "Reminder sent.",
|
|
1163
|
+
refreshAfter: true
|
|
1164
|
+
},
|
|
1165
|
+
{
|
|
1166
|
+
name: "approval_recall",
|
|
1167
|
+
label: "Recall",
|
|
1168
|
+
icon: "undo-2",
|
|
1169
|
+
type: "api",
|
|
1170
|
+
method: "POST",
|
|
1171
|
+
target: "/api/v1/approvals/requests/{id}/recall",
|
|
1172
|
+
params: [
|
|
1173
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1174
|
+
],
|
|
1175
|
+
// Recall applies while the request is live for the submitter — pending
|
|
1176
|
+
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1177
|
+
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
1178
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1179
|
+
locations: ["record_section"],
|
|
1180
|
+
successMessage: "Recalled.",
|
|
1181
|
+
refreshAfter: true
|
|
1182
|
+
},
|
|
1183
|
+
{
|
|
1184
|
+
name: "approval_resubmit",
|
|
1185
|
+
label: "Resubmit",
|
|
1186
|
+
icon: "refresh-cw",
|
|
1187
|
+
type: "api",
|
|
1188
|
+
method: "POST",
|
|
1189
|
+
target: "/api/v1/approvals/requests/{id}/resubmit",
|
|
1190
|
+
params: [
|
|
1191
|
+
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1192
|
+
],
|
|
1193
|
+
visible: 'record.status == "returned" && record.viewer.is_submitter',
|
|
1194
|
+
locations: ["record_section"],
|
|
1195
|
+
successMessage: "Resubmitted.",
|
|
1196
|
+
refreshAfter: true
|
|
1197
|
+
}
|
|
1198
|
+
],
|
|
1199
|
+
enable: {
|
|
1200
|
+
// [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
|
|
1201
|
+
// (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
|
|
1202
|
+
// generic CRUD. Reads stay open.
|
|
1203
|
+
apiMethods: ["get", "list"]
|
|
1204
|
+
}
|
|
844
1205
|
});
|
|
845
1206
|
|
|
846
1207
|
// src/sys-approval-action.object.ts
|
|
@@ -918,8 +1279,9 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
918
1279
|
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
|
|
919
1280
|
// remind / request_info / comment are thread interactions — they never
|
|
920
1281
|
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
|
|
921
|
-
// revision and the later resubmission.
|
|
922
|
-
|
|
1282
|
+
// revision and the later resubmission. ooo_substitute (#1322 M1) is a
|
|
1283
|
+
// system-recorded reroute of an out-of-office approver — no flow movement.
|
|
1284
|
+
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
923
1285
|
{
|
|
924
1286
|
label: "Action",
|
|
925
1287
|
required: true,
|
|
@@ -932,6 +1294,13 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
932
1294
|
group: "Action"
|
|
933
1295
|
}),
|
|
934
1296
|
comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1297
|
+
attachments: Field2.file({
|
|
1298
|
+
label: "Attachments",
|
|
1299
|
+
required: false,
|
|
1300
|
+
multiple: true,
|
|
1301
|
+
group: "Action",
|
|
1302
|
+
description: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
1303
|
+
}),
|
|
935
1304
|
created_at: Field2.datetime({
|
|
936
1305
|
label: "Created At",
|
|
937
1306
|
required: true,
|
|
@@ -943,7 +1312,12 @@ var SysApprovalAction = ObjectSchema2.create({
|
|
|
943
1312
|
indexes: [
|
|
944
1313
|
{ fields: ["request_id", "created_at"] },
|
|
945
1314
|
{ fields: ["request_id", "step_index", "action"] }
|
|
946
|
-
]
|
|
1315
|
+
],
|
|
1316
|
+
enable: {
|
|
1317
|
+
// [ADR-0103] Engine-owned append-only decision log: appended by the approval
|
|
1318
|
+
// engine (SYSTEM_CTX). Reads stay open.
|
|
1319
|
+
apiMethods: ["get", "list"]
|
|
1320
|
+
}
|
|
947
1321
|
});
|
|
948
1322
|
|
|
949
1323
|
// src/sys-approval-approver.object.ts
|
|
@@ -954,7 +1328,7 @@ var SysApprovalApprover = ObjectSchema3.create({
|
|
|
954
1328
|
pluralLabel: "Approval Approvers",
|
|
955
1329
|
icon: "users",
|
|
956
1330
|
isSystem: true,
|
|
957
|
-
managedBy: "
|
|
1331
|
+
managedBy: "engine-owned",
|
|
958
1332
|
description: "Normalized pending-approver rows for indexed inbox queries",
|
|
959
1333
|
displayNameField: "id",
|
|
960
1334
|
nameField: "id",
|
|
@@ -994,20 +1368,128 @@ var SysApprovalApprover = ObjectSchema3.create({
|
|
|
994
1368
|
{ fields: ["approver", "organization_id"] },
|
|
995
1369
|
// Sync path: rewrite all rows of one request on each approver-set change.
|
|
996
1370
|
{ fields: ["request_id"] }
|
|
997
|
-
]
|
|
1371
|
+
],
|
|
1372
|
+
enable: {
|
|
1373
|
+
// [ADR-0103] Engine-owned: approver rows are rewritten by the approval
|
|
1374
|
+
// engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
|
|
1375
|
+
apiMethods: ["get", "list"]
|
|
1376
|
+
}
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
// src/sys-approval-delegation.object.ts
|
|
1380
|
+
import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
|
|
1381
|
+
var SysApprovalDelegation = ObjectSchema4.create({
|
|
1382
|
+
name: "sys_approval_delegation",
|
|
1383
|
+
label: "Approval Delegation",
|
|
1384
|
+
pluralLabel: "Approval Delegations",
|
|
1385
|
+
icon: "user-clock",
|
|
1386
|
+
isSystem: true,
|
|
1387
|
+
managedBy: "system",
|
|
1388
|
+
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
|
|
1389
|
+
// authors their own out-of-office delegation. Affordance only (matches the
|
|
1390
|
+
// full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
|
|
1391
|
+
// keeps the system write guard from rejecting the self-service write.
|
|
1392
|
+
userActions: { create: true, edit: true, delete: true },
|
|
1393
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1394
|
+
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1395
|
+
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
1396
|
+
listViews: {
|
|
1397
|
+
active: {
|
|
1398
|
+
type: "grid",
|
|
1399
|
+
name: "active",
|
|
1400
|
+
label: "Active",
|
|
1401
|
+
data: { provider: "object", object: "sys_approval_delegation" },
|
|
1402
|
+
columns: ["delegator_id", "delegate_id", "valid_from", "valid_until", "reason"],
|
|
1403
|
+
sort: [{ field: "valid_until", order: "asc" }],
|
|
1404
|
+
pagination: { pageSize: 50 },
|
|
1405
|
+
emptyState: {
|
|
1406
|
+
title: "No delegations",
|
|
1407
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
},
|
|
1411
|
+
fields: {
|
|
1412
|
+
id: Field4.text({ label: "Delegation ID", required: true, readonly: true, group: "System" }),
|
|
1413
|
+
delegator_id: Field4.lookup("sys_user", {
|
|
1414
|
+
label: "Delegator",
|
|
1415
|
+
required: true,
|
|
1416
|
+
group: "Delegation",
|
|
1417
|
+
description: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
1418
|
+
}),
|
|
1419
|
+
delegate_id: Field4.lookup("sys_user", {
|
|
1420
|
+
label: "Delegate",
|
|
1421
|
+
required: true,
|
|
1422
|
+
group: "Delegation",
|
|
1423
|
+
description: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
1424
|
+
}),
|
|
1425
|
+
valid_from: Field4.datetime({
|
|
1426
|
+
label: "Valid From",
|
|
1427
|
+
required: false,
|
|
1428
|
+
group: "Delegation",
|
|
1429
|
+
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."
|
|
1430
|
+
}),
|
|
1431
|
+
valid_until: Field4.datetime({
|
|
1432
|
+
label: "Valid Until",
|
|
1433
|
+
required: false,
|
|
1434
|
+
group: "Delegation",
|
|
1435
|
+
description: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
1436
|
+
}),
|
|
1437
|
+
reason: Field4.text({
|
|
1438
|
+
label: "Reason",
|
|
1439
|
+
required: false,
|
|
1440
|
+
maxLength: 500,
|
|
1441
|
+
group: "Delegation",
|
|
1442
|
+
description: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
1443
|
+
}),
|
|
1444
|
+
organization_id: Field4.lookup("sys_organization", {
|
|
1445
|
+
label: "Organization",
|
|
1446
|
+
required: false,
|
|
1447
|
+
group: "System",
|
|
1448
|
+
description: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
1449
|
+
}),
|
|
1450
|
+
created_at: Field4.datetime({
|
|
1451
|
+
label: "Created At",
|
|
1452
|
+
defaultValue: "NOW()",
|
|
1453
|
+
readonly: true,
|
|
1454
|
+
group: "System"
|
|
1455
|
+
}),
|
|
1456
|
+
updated_at: Field4.datetime({
|
|
1457
|
+
label: "Updated At",
|
|
1458
|
+
defaultValue: "NOW()",
|
|
1459
|
+
readonly: true,
|
|
1460
|
+
group: "System"
|
|
1461
|
+
})
|
|
1462
|
+
},
|
|
1463
|
+
indexes: [
|
|
1464
|
+
// Resolution-time lookup: "active delegations for this delegator".
|
|
1465
|
+
{ fields: ["delegator_id", "organization_id"] },
|
|
1466
|
+
{ fields: ["delegate_id"] },
|
|
1467
|
+
{ fields: ["valid_until"] }
|
|
1468
|
+
],
|
|
1469
|
+
enable: {
|
|
1470
|
+
trackHistory: true,
|
|
1471
|
+
searchable: true,
|
|
1472
|
+
apiEnabled: true,
|
|
1473
|
+
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
1474
|
+
trash: true,
|
|
1475
|
+
mru: false
|
|
1476
|
+
}
|
|
998
1477
|
});
|
|
999
1478
|
|
|
1000
1479
|
// src/approval-service.ts
|
|
1001
1480
|
import { createHash, randomBytes } from "crypto";
|
|
1002
1481
|
import {
|
|
1003
|
-
APPROVAL_BRANCH_LABELS
|
|
1482
|
+
APPROVAL_BRANCH_LABELS,
|
|
1483
|
+
canonicalApproverType
|
|
1004
1484
|
} from "@objectstack/spec/automation";
|
|
1485
|
+
import { isGrantActive } from "@objectstack/core";
|
|
1005
1486
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1006
1487
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1007
1488
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1008
1489
|
var SLA_ACTOR_ID = "system:sla";
|
|
1009
1490
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1010
1491
|
var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
|
|
1492
|
+
var OOO_MAX_CHAIN = 8;
|
|
1011
1493
|
function uid(prefix) {
|
|
1012
1494
|
const g = globalThis;
|
|
1013
1495
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1080,6 +1562,10 @@ function rowFromAction(row) {
|
|
|
1080
1562
|
action: row.action,
|
|
1081
1563
|
actor_id: row.actor_id ?? void 0,
|
|
1082
1564
|
comment: row.comment ?? void 0,
|
|
1565
|
+
// Decision attachments (#3266). The column shipped in #3268 but this
|
|
1566
|
+
// contract mapping didn't — the raw engine row carried the fileIds while
|
|
1567
|
+
// every consumer of listActions saw none (caught by browser verification).
|
|
1568
|
+
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
|
|
1083
1569
|
created_at: row.created_at ?? void 0
|
|
1084
1570
|
};
|
|
1085
1571
|
}
|
|
@@ -1104,8 +1590,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1104
1590
|
async notify(input) {
|
|
1105
1591
|
const audience = input.audience.filter((a) => a && !a.includes(":"));
|
|
1106
1592
|
if (!this.messaging || !audience.length) return 0;
|
|
1593
|
+
let payload = input.payload;
|
|
1594
|
+
if (payload?.actionUrl === "/system/approvals" && input.source?.object === "sys_approval_request" && input.source.id) {
|
|
1595
|
+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
|
|
1596
|
+
}
|
|
1107
1597
|
try {
|
|
1108
|
-
await this.messaging.emit({ severity: "info", ...input, audience });
|
|
1598
|
+
await this.messaging.emit({ severity: "info", ...input, payload, audience });
|
|
1109
1599
|
return audience.length;
|
|
1110
1600
|
} catch (err) {
|
|
1111
1601
|
this.logger?.warn?.("[approvals] notification failed", {
|
|
@@ -1130,10 +1620,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1130
1620
|
}
|
|
1131
1621
|
/**
|
|
1132
1622
|
* 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.
|
|
1623
|
+
* graph tables for `team:` / `department:` / `position:` /
|
|
1624
|
+
* `org_membership_level:` / `manager:` approver types. Falls back to a
|
|
1625
|
+
* prefixed literal (`type:value`) when graph lookups produce nothing — so
|
|
1626
|
+
* existing fixtures and flows that rely on substring matching keep working.
|
|
1137
1627
|
*
|
|
1138
1628
|
* **Graph semantics:**
|
|
1139
1629
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
@@ -1141,66 +1631,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1141
1631
|
* → members of every descendant via `sys_business_unit_member`
|
|
1142
1632
|
* - `position` → holders via `sys_user_position` ∪ `sys_member.role`
|
|
1143
1633
|
* transition source (ADR-0090 D3 / ADR-0057 D4)
|
|
1144
|
-
* - `
|
|
1634
|
+
* - `org_membership_level`
|
|
1635
|
+
* → users with `sys_member.role = value` in tenant — the
|
|
1145
1636
|
* better-auth MEMBERSHIP TIER (owner/admin/member), not a
|
|
1146
1637
|
* position; author `position` for org positions
|
|
1147
1638
|
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
1148
1639
|
* - `field` → literal user id stored in `record[value]`
|
|
1149
1640
|
* - `user` → literal value
|
|
1641
|
+
*
|
|
1642
|
+
* `role` is accepted as the deprecated spelling of `org_membership_level`
|
|
1643
|
+
* (ADR-0090 D3) for one window: it resolves identically and logs a warning.
|
|
1644
|
+
*
|
|
1645
|
+
* **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
|
|
1646
|
+
* resolve to a specific person (`user` / `field` / `manager`) — are passed
|
|
1647
|
+
* through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
|
|
1648
|
+
* an active delegate when the resolved user has declared OOO. Group/graph
|
|
1649
|
+
* approvers (`team` / `department` / `position` / `org_membership_level`) are
|
|
1650
|
+
* left untouched: a group still has its other members, and position-routed
|
|
1651
|
+
* leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
|
|
1652
|
+
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1150
1653
|
*/
|
|
1151
|
-
async expandApprovers(step, record, organizationId) {
|
|
1654
|
+
async expandApprovers(step, record, organizationId, opts) {
|
|
1655
|
+
var _a;
|
|
1152
1656
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1657
|
+
const now = opts?.now ?? this.clock.now().getTime();
|
|
1153
1658
|
const out = [];
|
|
1154
|
-
|
|
1659
|
+
const specs = step.approvers;
|
|
1660
|
+
for (let idx = 0; idx < specs.length; idx++) {
|
|
1661
|
+
const a = specs[idx];
|
|
1155
1662
|
if (!a) continue;
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1663
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1664
|
+
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
1665
|
+
for (const u of ids) {
|
|
1666
|
+
if (!u) continue;
|
|
1667
|
+
out.push(u);
|
|
1668
|
+
if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
|
|
1159
1669
|
}
|
|
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
|
-
|
|
1670
|
+
}
|
|
1671
|
+
return out.filter(Boolean);
|
|
1672
|
+
}
|
|
1673
|
+
/**
|
|
1674
|
+
* Resolve ONE approver spec to concrete approver identities, applying OOO
|
|
1675
|
+
* substitution (#1322) to individually-routed types. Extracted from
|
|
1676
|
+
* {@link ApprovalService.expandApprovers} so the caller can tag each spec's
|
|
1677
|
+
* resolved ids with a group (#3266) without duplicating the resolution logic.
|
|
1678
|
+
* Returns the `type:value` literal as a single-element fallback when a graph
|
|
1679
|
+
* lookup yields nothing — same behaviour as before the extraction.
|
|
1680
|
+
*/
|
|
1681
|
+
async resolveApproverSpec(a, record, organizationId, now, substitutions) {
|
|
1682
|
+
const type = canonicalApproverType(String(a.type));
|
|
1683
|
+
if (type !== a.type) {
|
|
1684
|
+
this.logger?.warn?.(
|
|
1685
|
+
`[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) \u2014 author '${type}' instead`,
|
|
1686
|
+
{ deprecated: a.type, canonical: type }
|
|
1687
|
+
);
|
|
1688
|
+
}
|
|
1689
|
+
if (type === "user") {
|
|
1690
|
+
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1691
|
+
}
|
|
1692
|
+
if (type === "field" && record) {
|
|
1693
|
+
return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
|
|
1694
|
+
}
|
|
1695
|
+
try {
|
|
1696
|
+
if (type === "team") {
|
|
1697
|
+
const users = await this.expandTeamUsers(String(a.value));
|
|
1698
|
+
if (users.length) return users;
|
|
1699
|
+
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1700
|
+
const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
|
|
1701
|
+
if (users.length) return users;
|
|
1702
|
+
} else if (type === "position") {
|
|
1703
|
+
const users = await this.expandPositionUsers(String(a.value), organizationId);
|
|
1704
|
+
if (users.length) return users;
|
|
1705
|
+
} else if (type === "org_membership_level") {
|
|
1706
|
+
const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
|
|
1707
|
+
if (users.length) return users;
|
|
1708
|
+
} else if (type === "manager" && record) {
|
|
1709
|
+
const subject = record[a.value] ?? record.owner_id;
|
|
1710
|
+
if (subject) {
|
|
1711
|
+
const mgr = await this.lookupManager(String(subject));
|
|
1712
|
+
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
1198
1713
|
}
|
|
1199
|
-
} catch {
|
|
1200
1714
|
}
|
|
1201
|
-
|
|
1715
|
+
} catch {
|
|
1202
1716
|
}
|
|
1203
|
-
return
|
|
1717
|
+
return [`${a.type}:${a.value}`];
|
|
1204
1718
|
}
|
|
1205
1719
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1206
1720
|
async expandTeamUsers(teamId) {
|
|
@@ -1292,13 +1806,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1292
1806
|
}
|
|
1293
1807
|
} catch {
|
|
1294
1808
|
}
|
|
1295
|
-
for (const uid2 of await this.
|
|
1809
|
+
for (const uid2 of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid2);
|
|
1296
1810
|
return Array.from(users);
|
|
1297
1811
|
}
|
|
1298
|
-
/**
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1812
|
+
/**
|
|
1813
|
+
* better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
|
|
1814
|
+
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
1815
|
+
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
1816
|
+
* and stays, the platform-facing word does not.
|
|
1817
|
+
*/
|
|
1818
|
+
async expandMembershipTierUsers(tier, organizationId) {
|
|
1819
|
+
if (!tier) return [];
|
|
1820
|
+
const filter = { role: tier };
|
|
1302
1821
|
if (organizationId) filter.organization_id = organizationId;
|
|
1303
1822
|
let rows = [];
|
|
1304
1823
|
try {
|
|
@@ -1322,6 +1841,62 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1322
1841
|
return null;
|
|
1323
1842
|
}
|
|
1324
1843
|
}
|
|
1844
|
+
/**
|
|
1845
|
+
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
1846
|
+
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
1847
|
+
* slot should actually go to — the delegate acts under their own identity, so
|
|
1848
|
+
* no impersonation is involved. Returns `[userId]` unchanged when there is no
|
|
1849
|
+
* active delegation. Each hop is appended to `collector` (when supplied) so
|
|
1850
|
+
* the caller can audit + notify (M4).
|
|
1851
|
+
*
|
|
1852
|
+
* The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
|
|
1853
|
+
* stops on a self-reference or a cycle, so a mis-declared loop degrades to the
|
|
1854
|
+
* last reachable delegate rather than hanging.
|
|
1855
|
+
*/
|
|
1856
|
+
async applyOooDelegation(userId, now, organizationId, collector) {
|
|
1857
|
+
const start = String(userId ?? "").trim();
|
|
1858
|
+
if (!start) return [];
|
|
1859
|
+
let current = start;
|
|
1860
|
+
const visited = /* @__PURE__ */ new Set([current]);
|
|
1861
|
+
for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
|
|
1862
|
+
const del = await this.lookupActiveDelegation(current, now, organizationId);
|
|
1863
|
+
if (!del) break;
|
|
1864
|
+
const to = String(del.delegate_id ?? "").trim();
|
|
1865
|
+
if (!to || to === current || visited.has(to)) break;
|
|
1866
|
+
collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
|
|
1867
|
+
visited.add(to);
|
|
1868
|
+
current = to;
|
|
1869
|
+
}
|
|
1870
|
+
return [current];
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* The active OOO delegation for a delegator at `now`, or null. Validity is the
|
|
1874
|
+
* shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
|
|
1875
|
+
* resolution time — never by a background job. When several rows are active,
|
|
1876
|
+
* the one expiring soonest wins (the most specific coverage window).
|
|
1877
|
+
*/
|
|
1878
|
+
async lookupActiveDelegation(delegatorId, now, organizationId) {
|
|
1879
|
+
if (!delegatorId) return null;
|
|
1880
|
+
let rows = [];
|
|
1881
|
+
try {
|
|
1882
|
+
rows = await this.engine.find("sys_approval_delegation", {
|
|
1883
|
+
filter: { delegator_id: delegatorId },
|
|
1884
|
+
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1885
|
+
limit: 50,
|
|
1886
|
+
context: SYSTEM_CTX
|
|
1887
|
+
});
|
|
1888
|
+
} catch {
|
|
1889
|
+
return null;
|
|
1890
|
+
}
|
|
1891
|
+
const active = (rows ?? []).filter((r) => isGrantActive(r, now) && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
|
|
1892
|
+
if (!active.length) return null;
|
|
1893
|
+
active.sort((a, b) => {
|
|
1894
|
+
const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1895
|
+
const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1896
|
+
return au - bu;
|
|
1897
|
+
});
|
|
1898
|
+
return active[0];
|
|
1899
|
+
}
|
|
1325
1900
|
/** Mirror a request status onto a business-object field, if configured. */
|
|
1326
1901
|
async mirrorStatusField(object, recordId, field, status) {
|
|
1327
1902
|
try {
|
|
@@ -1355,13 +1930,24 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1355
1930
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
1356
1931
|
}
|
|
1357
1932
|
const ctxOrg = context?.organizationId ?? context?.tenantId ?? input.organizationId ?? null;
|
|
1358
|
-
const
|
|
1359
|
-
const
|
|
1933
|
+
const nowDate = this.clock.now();
|
|
1934
|
+
const substitutions = [];
|
|
1935
|
+
const groups = {};
|
|
1936
|
+
const approvers = await this.expandApprovers(
|
|
1937
|
+
{ approvers: input.config.approvers },
|
|
1938
|
+
input.record,
|
|
1939
|
+
ctxOrg,
|
|
1940
|
+
{ now: nowDate.getTime(), substitutions, groups }
|
|
1941
|
+
);
|
|
1942
|
+
const now = nowDate.toISOString();
|
|
1360
1943
|
const id = uid("areq");
|
|
1361
1944
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1362
1945
|
const configSnapshot = { ...input.config };
|
|
1363
1946
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1364
1947
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1948
|
+
if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
|
|
1949
|
+
configSnapshot.__approverGroups = groups;
|
|
1950
|
+
}
|
|
1365
1951
|
try {
|
|
1366
1952
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1367
1953
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
@@ -1403,17 +1989,83 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1403
1989
|
comment: null,
|
|
1404
1990
|
created_at: now
|
|
1405
1991
|
}, { context: SYSTEM_CTX });
|
|
1992
|
+
for (const sub of substitutions) {
|
|
1993
|
+
await this.engine.insert("sys_approval_action", {
|
|
1994
|
+
id: uid("aact"),
|
|
1995
|
+
request_id: id,
|
|
1996
|
+
organization_id: ctxOrg,
|
|
1997
|
+
step_name: input.nodeId,
|
|
1998
|
+
step_index: 0,
|
|
1999
|
+
action: "ooo_substitute",
|
|
2000
|
+
actor_id: null,
|
|
2001
|
+
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2002
|
+
created_at: now
|
|
2003
|
+
}, { context: SYSTEM_CTX });
|
|
2004
|
+
await this.notify({
|
|
2005
|
+
topic: "approval.ooo_substituted",
|
|
2006
|
+
audience: [sub.to],
|
|
2007
|
+
source: { object: "sys_approval_request", id },
|
|
2008
|
+
dedupKey: `approval-ooo-${id}-${sub.to}`,
|
|
2009
|
+
payload: {
|
|
2010
|
+
title: "Approval routed to you (out-of-office cover)",
|
|
2011
|
+
message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
|
|
2012
|
+
actionUrl: "/system/approvals"
|
|
2013
|
+
}
|
|
2014
|
+
});
|
|
2015
|
+
await this.notify({
|
|
2016
|
+
topic: "approval.ooo_skipped",
|
|
2017
|
+
audience: [sub.from],
|
|
2018
|
+
source: { object: "sys_approval_request", id },
|
|
2019
|
+
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
|
|
2020
|
+
payload: {
|
|
2021
|
+
title: "Approval routed to your delegate",
|
|
2022
|
+
message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
|
|
2023
|
+
actionUrl: "/system/approvals"
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
1406
2027
|
if (input.config.approvalStatusField) {
|
|
1407
2028
|
await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
|
|
1408
2029
|
}
|
|
1409
2030
|
return rowFromRequest(row);
|
|
1410
2031
|
}
|
|
1411
2032
|
/**
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1415
|
-
*
|
|
1416
|
-
*
|
|
2033
|
+
* True when the approve tally satisfies the node's `behavior` (#3266):
|
|
2034
|
+
* - `unanimous` — every resolved approver approved.
|
|
2035
|
+
* - `quorum` — at least `minApprovals` distinct approvals (default = all).
|
|
2036
|
+
* - `per_group` — every group reached `minApprovals` approvals (default 1).
|
|
2037
|
+
* Thresholds are clamped to the resolvable count / group size, so a mis-set
|
|
2038
|
+
* value can never deadlock a request.
|
|
2039
|
+
*/
|
|
2040
|
+
isApprovalSatisfied(behavior, config, original, groupMap, approved) {
|
|
2041
|
+
if (behavior === "unanimous") {
|
|
2042
|
+
return original.length > 0 && original.every((a) => approved.has(a));
|
|
2043
|
+
}
|
|
2044
|
+
if (behavior === "quorum") {
|
|
2045
|
+
const n = original.length || 1;
|
|
2046
|
+
const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
|
|
2047
|
+
return approved.size >= need;
|
|
2048
|
+
}
|
|
2049
|
+
if (behavior === "per_group") {
|
|
2050
|
+
const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
|
|
2051
|
+
const size = {};
|
|
2052
|
+
for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
2053
|
+
const groups = Object.keys(size);
|
|
2054
|
+
if (!groups.length) return true;
|
|
2055
|
+
const got = {};
|
|
2056
|
+
for (const a of approved) for (const g of groupMap[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
2057
|
+
return groups.every((g) => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
|
|
2058
|
+
}
|
|
2059
|
+
return true;
|
|
2060
|
+
}
|
|
2061
|
+
/**
|
|
2062
|
+
* Record a decision on a node-driven request. Honours the node's `behavior`
|
|
2063
|
+
* (#3266): `first_response` finalizes on the first approval; `unanimous`,
|
|
2064
|
+
* `quorum`, and `per_group` hold the request open until their tally is met
|
|
2065
|
+
* (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
|
|
2066
|
+
* finalizes the node (one veto). When the request finalizes, returns the
|
|
2067
|
+
* suspended run id + node id so the caller (or {@link ApprovalService.decide})
|
|
2068
|
+
* can resume the flow down the matching branch.
|
|
1417
2069
|
*/
|
|
1418
2070
|
async decideNode(requestId, input, context) {
|
|
1419
2071
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -1447,22 +2099,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1447
2099
|
action: input.decision,
|
|
1448
2100
|
actor_id: input.actorId,
|
|
1449
2101
|
comment: input.comment ?? null,
|
|
2102
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
1450
2103
|
created_at: now
|
|
1451
2104
|
}, { context: SYSTEM_CTX });
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
{ approvers: config.approvers },
|
|
1455
|
-
parseJson(raw.payload_json, void 0),
|
|
1456
|
-
org
|
|
1457
|
-
);
|
|
2105
|
+
const behavior = config.behavior ?? "first_response";
|
|
2106
|
+
if (input.decision === "approve" && behavior !== "first_response") {
|
|
1458
2107
|
const acts = await this.engine.find("sys_approval_action", {
|
|
1459
2108
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
1460
|
-
limit:
|
|
2109
|
+
limit: 1e3,
|
|
1461
2110
|
context: SYSTEM_CTX
|
|
1462
2111
|
});
|
|
1463
2112
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
1464
|
-
const
|
|
1465
|
-
|
|
2113
|
+
const snapshotGroups = config.__approverGroups;
|
|
2114
|
+
let original;
|
|
2115
|
+
let groupMap;
|
|
2116
|
+
if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
|
|
2117
|
+
groupMap = snapshotGroups;
|
|
2118
|
+
original = Object.keys(snapshotGroups);
|
|
2119
|
+
} else {
|
|
2120
|
+
original = await this.expandApprovers(
|
|
2121
|
+
{ approvers: config.approvers },
|
|
2122
|
+
parseJson(raw.payload_json, void 0),
|
|
2123
|
+
org
|
|
2124
|
+
);
|
|
2125
|
+
groupMap = {};
|
|
2126
|
+
}
|
|
2127
|
+
if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
|
|
2128
|
+
const stillPending = original.filter((a) => !approved.has(a));
|
|
1466
2129
|
await this.engine.update("sys_approval_request", {
|
|
1467
2130
|
id: requestId,
|
|
1468
2131
|
pending_approvers: stillPending.join(","),
|
|
@@ -1896,10 +2559,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1896
2559
|
comment: input.comment ?? `${from} \u2192 ${to}`,
|
|
1897
2560
|
created_at: now
|
|
1898
2561
|
}, { context: SYSTEM_CTX });
|
|
2562
|
+
let configPatch = {};
|
|
2563
|
+
try {
|
|
2564
|
+
const cfg = parseJson(raw.node_config_json, null);
|
|
2565
|
+
const groups = cfg?.__approverGroups;
|
|
2566
|
+
if (groups && groups[from] && !groups[to]) {
|
|
2567
|
+
groups[to] = groups[from];
|
|
2568
|
+
delete groups[from];
|
|
2569
|
+
configPatch = { node_config_json: JSON.stringify(cfg) };
|
|
2570
|
+
}
|
|
2571
|
+
} catch {
|
|
2572
|
+
}
|
|
1899
2573
|
await this.engine.update("sys_approval_request", {
|
|
1900
2574
|
id: requestId,
|
|
1901
2575
|
pending_approvers: next.join(","),
|
|
1902
|
-
updated_at: now
|
|
2576
|
+
updated_at: now,
|
|
2577
|
+
...configPatch
|
|
1903
2578
|
}, { context: SYSTEM_CTX });
|
|
1904
2579
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
1905
2580
|
await this.notify({
|
|
@@ -2144,6 +2819,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2144
2819
|
action: "comment",
|
|
2145
2820
|
actor_id: input.actorId,
|
|
2146
2821
|
comment: input.comment.trim(),
|
|
2822
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
2147
2823
|
created_at: now
|
|
2148
2824
|
}, { context: SYSTEM_CTX });
|
|
2149
2825
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
@@ -2661,6 +3337,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2661
3337
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
2662
3338
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
2663
3339
|
await this.enrichRows(list);
|
|
3340
|
+
this.attachViewers(list, context);
|
|
2664
3341
|
return list;
|
|
2665
3342
|
}
|
|
2666
3343
|
async countRequests(filter, context) {
|
|
@@ -2701,8 +3378,74 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2701
3378
|
const row = rowFromRequest(rows[0]);
|
|
2702
3379
|
await this.enrichRows([row]);
|
|
2703
3380
|
await this.attachFlowSteps(row);
|
|
3381
|
+
await this.attachDecisionProgress(row, rows[0]);
|
|
3382
|
+
this.attachViewers([row], context);
|
|
2704
3383
|
return row;
|
|
2705
3384
|
}
|
|
3385
|
+
/**
|
|
3386
|
+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
|
|
3387
|
+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
|
|
3388
|
+
* for a PENDING request whose behavior aggregates multiple approvals
|
|
3389
|
+
* (`unanimous` / `quorum` / `per_group`), expose
|
|
3390
|
+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
|
|
3391
|
+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
|
|
3392
|
+
* `first_response` requests carry no progress (one approval finalizes).
|
|
3393
|
+
* Display-only and best-effort — errors leave the row untouched.
|
|
3394
|
+
*/
|
|
3395
|
+
async attachDecisionProgress(row, raw) {
|
|
3396
|
+
try {
|
|
3397
|
+
if (row.status !== "pending") return;
|
|
3398
|
+
const cfg = parseJson(raw.node_config_json, void 0);
|
|
3399
|
+
const behavior = cfg?.behavior ?? "first_response";
|
|
3400
|
+
if (behavior !== "unanimous" && behavior !== "quorum" && behavior !== "per_group") return;
|
|
3401
|
+
const acts = await this.engine.find("sys_approval_action", {
|
|
3402
|
+
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3403
|
+
limit: 1e3,
|
|
3404
|
+
context: SYSTEM_CTX
|
|
3405
|
+
});
|
|
3406
|
+
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3407
|
+
const snapshot = cfg?.__approverGroups;
|
|
3408
|
+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...row.pending_approvers ?? []];
|
|
3409
|
+
const total = slate.length || 1;
|
|
3410
|
+
const progress = { behavior, got: approved.size, need: total };
|
|
3411
|
+
if (behavior === "quorum") {
|
|
3412
|
+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
|
|
3413
|
+
} else if (behavior === "per_group" && snapshot) {
|
|
3414
|
+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
|
|
3415
|
+
const size = {};
|
|
3416
|
+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
3417
|
+
const got = {};
|
|
3418
|
+
for (const a of approved) for (const g of snapshot[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
3419
|
+
progress.groups = Object.keys(size).sort().map((g) => {
|
|
3420
|
+
const need = Math.min(perGroupNeed, size[g]);
|
|
3421
|
+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
|
|
3422
|
+
});
|
|
3423
|
+
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3424
|
+
progress.need = progress.groups.length;
|
|
3425
|
+
}
|
|
3426
|
+
row.decision_progress = progress;
|
|
3427
|
+
} catch {
|
|
3428
|
+
}
|
|
3429
|
+
}
|
|
3430
|
+
/**
|
|
3431
|
+
* Attach the per-viewer capability block (#3310) from the caller's context.
|
|
3432
|
+
* `can_act` mirrors the exact authorization the decision methods enforce — the
|
|
3433
|
+
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
3434
|
+
* still `pending` (position/team/manager approvers are already resolved to
|
|
3435
|
+
* concrete user ids at open time, so a plain membership test is faithful).
|
|
3436
|
+
* `is_submitter` is a straight owner check. System/tokenless contexts get a
|
|
3437
|
+
* both-false block. Cheap + synchronous — safe on list reads.
|
|
3438
|
+
*/
|
|
3439
|
+
attachViewers(rows, context) {
|
|
3440
|
+
const uid2 = context?.userId != null ? String(context.userId) : null;
|
|
3441
|
+
for (const row of rows) {
|
|
3442
|
+
const pending = row.pending_approvers ?? [];
|
|
3443
|
+
row.viewer = {
|
|
3444
|
+
can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
|
|
3445
|
+
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
|
|
3446
|
+
};
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
2706
3449
|
/**
|
|
2707
3450
|
* Derive approval-step progress from the owning flow's graph (single-read
|
|
2708
3451
|
* enrichment only — list reads skip it). Walks from the start node
|
|
@@ -2764,26 +3507,26 @@ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
|
2764
3507
|
var ApprovalService = _ApprovalService;
|
|
2765
3508
|
|
|
2766
3509
|
// src/sys-approval-token.object.ts
|
|
2767
|
-
import { ObjectSchema as
|
|
2768
|
-
var SysApprovalToken =
|
|
3510
|
+
import { ObjectSchema as ObjectSchema5, Field as Field5 } from "@objectstack/spec/data";
|
|
3511
|
+
var SysApprovalToken = ObjectSchema5.create({
|
|
2769
3512
|
name: "sys_approval_token",
|
|
2770
3513
|
label: "Approval Action Token",
|
|
2771
3514
|
pluralLabel: "Approval Action Tokens",
|
|
2772
3515
|
icon: "key",
|
|
2773
3516
|
isSystem: true,
|
|
2774
|
-
managedBy: "
|
|
3517
|
+
managedBy: "engine-owned",
|
|
2775
3518
|
description: "Single-use tokens behind actionable approval links",
|
|
2776
3519
|
displayNameField: "id",
|
|
2777
3520
|
nameField: "id",
|
|
2778
3521
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
2779
3522
|
fields: {
|
|
2780
|
-
id:
|
|
2781
|
-
organization_id:
|
|
3523
|
+
id: Field5.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
|
|
3524
|
+
organization_id: Field5.lookup("sys_organization", {
|
|
2782
3525
|
label: "Organization",
|
|
2783
3526
|
required: false,
|
|
2784
3527
|
group: "System"
|
|
2785
3528
|
}),
|
|
2786
|
-
token_hash:
|
|
3529
|
+
token_hash: Field5.text({
|
|
2787
3530
|
label: "Token Hash",
|
|
2788
3531
|
required: true,
|
|
2789
3532
|
maxLength: 100,
|
|
@@ -2791,20 +3534,20 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2791
3534
|
description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
|
|
2792
3535
|
group: "Token"
|
|
2793
3536
|
}),
|
|
2794
|
-
request_id:
|
|
3537
|
+
request_id: Field5.text({
|
|
2795
3538
|
label: "Request",
|
|
2796
3539
|
required: true,
|
|
2797
3540
|
maxLength: 100,
|
|
2798
3541
|
readonly: true,
|
|
2799
3542
|
group: "Token"
|
|
2800
3543
|
}),
|
|
2801
|
-
action:
|
|
3544
|
+
action: Field5.select(["approve", "reject"], {
|
|
2802
3545
|
label: "Action",
|
|
2803
3546
|
required: true,
|
|
2804
3547
|
readonly: true,
|
|
2805
3548
|
group: "Token"
|
|
2806
3549
|
}),
|
|
2807
|
-
approver_id:
|
|
3550
|
+
approver_id: Field5.text({
|
|
2808
3551
|
label: "Approver",
|
|
2809
3552
|
required: true,
|
|
2810
3553
|
maxLength: 200,
|
|
@@ -2812,18 +3555,18 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2812
3555
|
description: "Identity the token is bound to; the decision is audited as this approver",
|
|
2813
3556
|
group: "Token"
|
|
2814
3557
|
}),
|
|
2815
|
-
expires_at:
|
|
3558
|
+
expires_at: Field5.datetime({
|
|
2816
3559
|
label: "Expires At",
|
|
2817
3560
|
required: true,
|
|
2818
3561
|
readonly: true,
|
|
2819
3562
|
group: "Lifecycle"
|
|
2820
3563
|
}),
|
|
2821
|
-
consumed_at:
|
|
3564
|
+
consumed_at: Field5.datetime({
|
|
2822
3565
|
label: "Consumed At",
|
|
2823
3566
|
required: false,
|
|
2824
3567
|
group: "Lifecycle"
|
|
2825
3568
|
}),
|
|
2826
|
-
created_at:
|
|
3569
|
+
created_at: Field5.datetime({
|
|
2827
3570
|
label: "Created At",
|
|
2828
3571
|
required: true,
|
|
2829
3572
|
defaultValue: "NOW()",
|
|
@@ -2834,7 +3577,12 @@ var SysApprovalToken = ObjectSchema4.create({
|
|
|
2834
3577
|
indexes: [
|
|
2835
3578
|
{ fields: ["token_hash"] },
|
|
2836
3579
|
{ fields: ["request_id"] }
|
|
2837
|
-
]
|
|
3580
|
+
],
|
|
3581
|
+
enable: {
|
|
3582
|
+
// [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
|
|
3583
|
+
// consumed by the approval engine (SYSTEM_CTX), never via the data API.
|
|
3584
|
+
apiMethods: ["get", "list"]
|
|
3585
|
+
}
|
|
2838
3586
|
});
|
|
2839
3587
|
|
|
2840
3588
|
// src/action-link-pages.ts
|
|
@@ -2958,6 +3706,40 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
2958
3706
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
2959
3707
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
2960
3708
|
}
|
|
3709
|
+
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
3710
|
+
function bindDelegationWriteGuard(engine, logger) {
|
|
3711
|
+
const makeGuard = (isInsert) => async (ctx) => {
|
|
3712
|
+
const session = ctx?.session ?? {};
|
|
3713
|
+
if (session.isSystem) return;
|
|
3714
|
+
const roles = session.roles ?? [];
|
|
3715
|
+
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3716
|
+
const userId = session.userId != null ? String(session.userId) : "";
|
|
3717
|
+
const data = ctx?.input?.data;
|
|
3718
|
+
const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
|
|
3719
|
+
const deny = () => {
|
|
3720
|
+
const err = new Error(
|
|
3721
|
+
"FORBIDDEN: you may only manage out-of-office delegations where you are the delegator" + (userId ? ` ('${userId}')` : "")
|
|
3722
|
+
);
|
|
3723
|
+
err.code = "FORBIDDEN";
|
|
3724
|
+
err.statusCode = 403;
|
|
3725
|
+
throw err;
|
|
3726
|
+
};
|
|
3727
|
+
for (const row of rows) {
|
|
3728
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
3729
|
+
const has = Object.prototype.hasOwnProperty.call(row, "delegator_id");
|
|
3730
|
+
const supplied = has ? String(row.delegator_id ?? "") : "";
|
|
3731
|
+
if (isInsert && (!has || supplied === "")) {
|
|
3732
|
+
if (!userId) deny();
|
|
3733
|
+
row.delegator_id = userId;
|
|
3734
|
+
continue;
|
|
3735
|
+
}
|
|
3736
|
+
if (has && supplied !== userId) deny();
|
|
3737
|
+
}
|
|
3738
|
+
};
|
|
3739
|
+
engine.registerHook("beforeInsert", makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3740
|
+
engine.registerHook("beforeUpdate", makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3741
|
+
logger?.info?.("[approvals] delegation write-guard bound");
|
|
3742
|
+
}
|
|
2961
3743
|
function unbindAllHooks(engine) {
|
|
2962
3744
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
2963
3745
|
}
|
|
@@ -3058,7 +3840,7 @@ var ApprovalsServicePlugin = class {
|
|
|
3058
3840
|
scope: "system",
|
|
3059
3841
|
defaultDatasource: "cloud",
|
|
3060
3842
|
namespace: "sys",
|
|
3061
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
|
|
3843
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
|
|
3062
3844
|
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
3063
3845
|
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
3064
3846
|
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
@@ -3069,7 +3851,8 @@ var ApprovalsServicePlugin = class {
|
|
|
3069
3851
|
priority: 100,
|
|
3070
3852
|
items: [
|
|
3071
3853
|
{ 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" }
|
|
3854
|
+
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" },
|
|
3855
|
+
{ id: "nav_approval_delegations", type: "object", label: "Delegations (OOO)", objectName: "sys_approval_delegation", icon: "user-clock", requiresObject: "sys_approval_delegation" }
|
|
3073
3856
|
]
|
|
3074
3857
|
}
|
|
3075
3858
|
]
|
|
@@ -3115,8 +3898,9 @@ var ApprovalsServicePlugin = class {
|
|
|
3115
3898
|
try {
|
|
3116
3899
|
unbindAllHooks(engine);
|
|
3117
3900
|
bindApprovalLockHook(engine, ctx.logger);
|
|
3901
|
+
bindDelegationWriteGuard(engine, ctx.logger);
|
|
3118
3902
|
} catch (err) {
|
|
3119
|
-
ctx.logger.warn?.("[approvals] failed to bind
|
|
3903
|
+
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
3120
3904
|
}
|
|
3121
3905
|
}
|
|
3122
3906
|
ctx.registerService("approvals", this.service);
|
|
@@ -3233,6 +4017,7 @@ export {
|
|
|
3233
4017
|
ApprovalsServicePlugin,
|
|
3234
4018
|
SysApprovalAction,
|
|
3235
4019
|
SysApprovalApprover,
|
|
4020
|
+
SysApprovalDelegation,
|
|
3236
4021
|
SysApprovalRequest,
|
|
3237
4022
|
registerApprovalNode
|
|
3238
4023
|
};
|