@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.js
CHANGED
|
@@ -174,6 +174,55 @@ var init_en_objects_generated = __esm({
|
|
|
174
174
|
label: "All"
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
+
},
|
|
178
|
+
sys_approval_delegation: {
|
|
179
|
+
label: "Approval Delegation",
|
|
180
|
+
pluralLabel: "Approval Delegations",
|
|
181
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
182
|
+
fields: {
|
|
183
|
+
id: {
|
|
184
|
+
label: "Delegation ID"
|
|
185
|
+
},
|
|
186
|
+
delegator_id: {
|
|
187
|
+
label: "Delegator",
|
|
188
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
189
|
+
},
|
|
190
|
+
delegate_id: {
|
|
191
|
+
label: "Delegate",
|
|
192
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
193
|
+
},
|
|
194
|
+
valid_from: {
|
|
195
|
+
label: "Valid From",
|
|
196
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
197
|
+
},
|
|
198
|
+
valid_until: {
|
|
199
|
+
label: "Valid Until",
|
|
200
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
201
|
+
},
|
|
202
|
+
reason: {
|
|
203
|
+
label: "Reason",
|
|
204
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
205
|
+
},
|
|
206
|
+
organization_id: {
|
|
207
|
+
label: "Organization",
|
|
208
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
209
|
+
},
|
|
210
|
+
created_at: {
|
|
211
|
+
label: "Created At"
|
|
212
|
+
},
|
|
213
|
+
updated_at: {
|
|
214
|
+
label: "Updated At"
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
_views: {
|
|
218
|
+
active: {
|
|
219
|
+
label: "Active",
|
|
220
|
+
emptyState: {
|
|
221
|
+
title: "No delegations",
|
|
222
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
177
226
|
}
|
|
178
227
|
};
|
|
179
228
|
}
|
|
@@ -328,6 +377,55 @@ var init_zh_CN_objects_generated = __esm({
|
|
|
328
377
|
label: "\u5168\u90E8"
|
|
329
378
|
}
|
|
330
379
|
}
|
|
380
|
+
},
|
|
381
|
+
sys_approval_delegation: {
|
|
382
|
+
label: "\u5BA1\u6279\u59D4\u6D3E",
|
|
383
|
+
pluralLabel: "\u5BA1\u6279\u59D4\u6D3E",
|
|
384
|
+
description: "\u81EA\u52A9\u4E0D\u5728\u5C97\u89C4\u5219\uFF1A\u5728\u65F6\u95F4\u7A97\u5185\u628A\u8BE5\u7528\u6237\u7684\u5BA1\u6279\u4EBA\u69FD\u4F4D\u6539\u6D3E\u7ED9\u5019\u8865\u4EBA\uFF08#1322 M1\uFF09\u3002",
|
|
385
|
+
fields: {
|
|
386
|
+
id: {
|
|
387
|
+
label: "\u59D4\u6D3E ID"
|
|
388
|
+
},
|
|
389
|
+
delegator_id: {
|
|
390
|
+
label: "\u59D4\u6D3E\u4EBA",
|
|
391
|
+
help: "\u5373\u5C06\u4E0D\u5728\u5C97\u7684\u7528\u6237\uFF1B\u89C4\u5219\u751F\u6548\u671F\u95F4\uFF0C\u8DEF\u7531\u5230\u5176\u4E2A\u4EBA\u7684\u5BA1\u6279\u4EBA\u69FD\u4F4D\u5C06\u88AB\u6539\u6D3E\u3002"
|
|
392
|
+
},
|
|
393
|
+
delegate_id: {
|
|
394
|
+
label: "\u5019\u8865\u4EBA",
|
|
395
|
+
help: "\u89C4\u5219\u751F\u6548\u671F\u95F4\u63A5\u6536\u59D4\u6D3E\u4EBA\u5BA1\u6279\u7684\u5019\u8865\u4EBA\u3002\u4EE5\u5176\u672C\u4EBA\u8EAB\u4EFD\u884C\u4F7F\u3002"
|
|
396
|
+
},
|
|
397
|
+
valid_from: {
|
|
398
|
+
label: "\u751F\u6548\u65F6\u95F4",
|
|
399
|
+
help: "\u6B64\u523B\u4E4B\u524D\u89C4\u5219\u4E0D\u751F\u6548\u3002\u7559\u7A7A = \u7ACB\u5373\u751F\u6548\u3002\u5728\u89E3\u6790\u65F6\u901A\u8FC7 isGrantActive \u5F3A\u5236\uFF08ADR-0091 D2 \u5224\u5B9A\u5F0F\uFF09\u2014\u2014\u7EDD\u4E0D\u4F9D\u8D56\u540E\u53F0\u4EFB\u52A1\u3002"
|
|
400
|
+
},
|
|
401
|
+
valid_until: {
|
|
402
|
+
label: "\u5931\u6548\u65F6\u95F4",
|
|
403
|
+
help: "\u6B64\u523B\uFF08\u542B\uFF09\u53CA\u4E4B\u540E\u89C4\u5219\u4E0D\u751F\u6548\uFF08\u534A\u5F00\u533A\u95F4 [from, until)\uFF0CUTC\uFF09\u3002\u7559\u7A7A = \u6C38\u4E0D\u8FC7\u671F\u3002"
|
|
404
|
+
},
|
|
405
|
+
reason: {
|
|
406
|
+
label: "\u539F\u56E0",
|
|
407
|
+
help: "\u59D4\u6D3E\u5B58\u5728\u7684\u539F\u56E0\uFF08\u4F8B\u5982\u201C\u5E74\u5047 5/26\u20135/30\u201D\uFF09\u3002\u4F1A\u8BB0\u5165\u6539\u6D3E\u5BA1\u8BA1\u884C\u3002"
|
|
408
|
+
},
|
|
409
|
+
organization_id: {
|
|
410
|
+
label: "\u7EC4\u7EC7",
|
|
411
|
+
help: "\u62E5\u6709\u6B64\u89C4\u5219\u7684\u79DF\u6237\uFF1B\u7559\u7A7A = \u5BF9\u8BE5\u59D4\u6D3E\u4EBA\u8DE8\u79DF\u6237\u751F\u6548\u3002"
|
|
412
|
+
},
|
|
413
|
+
created_at: {
|
|
414
|
+
label: "\u521B\u5EFA\u65F6\u95F4"
|
|
415
|
+
},
|
|
416
|
+
updated_at: {
|
|
417
|
+
label: "\u66F4\u65B0\u65F6\u95F4"
|
|
418
|
+
}
|
|
419
|
+
},
|
|
420
|
+
_views: {
|
|
421
|
+
active: {
|
|
422
|
+
label: "\u751F\u6548\u4E2D",
|
|
423
|
+
emptyState: {
|
|
424
|
+
title: "\u6682\u65E0\u59D4\u6D3E",
|
|
425
|
+
message: "\u58F0\u660E\u4E00\u6761\u4E0D\u5728\u5C97\u59D4\u6D3E\uFF0C\u4F11\u5047\u671F\u95F4\u5BA1\u6279\u81EA\u52A8\u6539\u6D3E\u7ED9\u5019\u8865\u4EBA\u3002"
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
331
429
|
}
|
|
332
430
|
};
|
|
333
431
|
}
|
|
@@ -482,6 +580,55 @@ var init_ja_JP_objects_generated = __esm({
|
|
|
482
580
|
label: "\u3059\u3079\u3066"
|
|
483
581
|
}
|
|
484
582
|
}
|
|
583
|
+
},
|
|
584
|
+
sys_approval_delegation: {
|
|
585
|
+
label: "Approval Delegation",
|
|
586
|
+
pluralLabel: "Approval Delegations",
|
|
587
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
588
|
+
fields: {
|
|
589
|
+
id: {
|
|
590
|
+
label: "Delegation ID"
|
|
591
|
+
},
|
|
592
|
+
delegator_id: {
|
|
593
|
+
label: "Delegator",
|
|
594
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
595
|
+
},
|
|
596
|
+
delegate_id: {
|
|
597
|
+
label: "Delegate",
|
|
598
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
599
|
+
},
|
|
600
|
+
valid_from: {
|
|
601
|
+
label: "Valid From",
|
|
602
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
603
|
+
},
|
|
604
|
+
valid_until: {
|
|
605
|
+
label: "Valid Until",
|
|
606
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
607
|
+
},
|
|
608
|
+
reason: {
|
|
609
|
+
label: "Reason",
|
|
610
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
611
|
+
},
|
|
612
|
+
organization_id: {
|
|
613
|
+
label: "Organization",
|
|
614
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
615
|
+
},
|
|
616
|
+
created_at: {
|
|
617
|
+
label: "Created At"
|
|
618
|
+
},
|
|
619
|
+
updated_at: {
|
|
620
|
+
label: "Updated At"
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
_views: {
|
|
624
|
+
active: {
|
|
625
|
+
label: "Active",
|
|
626
|
+
emptyState: {
|
|
627
|
+
title: "No delegations",
|
|
628
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
485
632
|
}
|
|
486
633
|
};
|
|
487
634
|
}
|
|
@@ -636,6 +783,55 @@ var init_es_ES_objects_generated = __esm({
|
|
|
636
783
|
label: "Todas"
|
|
637
784
|
}
|
|
638
785
|
}
|
|
786
|
+
},
|
|
787
|
+
sys_approval_delegation: {
|
|
788
|
+
label: "Approval Delegation",
|
|
789
|
+
pluralLabel: "Approval Delegations",
|
|
790
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
791
|
+
fields: {
|
|
792
|
+
id: {
|
|
793
|
+
label: "Delegation ID"
|
|
794
|
+
},
|
|
795
|
+
delegator_id: {
|
|
796
|
+
label: "Delegator",
|
|
797
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
798
|
+
},
|
|
799
|
+
delegate_id: {
|
|
800
|
+
label: "Delegate",
|
|
801
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
802
|
+
},
|
|
803
|
+
valid_from: {
|
|
804
|
+
label: "Valid From",
|
|
805
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
|
|
806
|
+
},
|
|
807
|
+
valid_until: {
|
|
808
|
+
label: "Valid Until",
|
|
809
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
810
|
+
},
|
|
811
|
+
reason: {
|
|
812
|
+
label: "Reason",
|
|
813
|
+
help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
814
|
+
},
|
|
815
|
+
organization_id: {
|
|
816
|
+
label: "Organization",
|
|
817
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
818
|
+
},
|
|
819
|
+
created_at: {
|
|
820
|
+
label: "Created At"
|
|
821
|
+
},
|
|
822
|
+
updated_at: {
|
|
823
|
+
label: "Updated At"
|
|
824
|
+
}
|
|
825
|
+
},
|
|
826
|
+
_views: {
|
|
827
|
+
active: {
|
|
828
|
+
label: "Active",
|
|
829
|
+
emptyState: {
|
|
830
|
+
title: "No delegations",
|
|
831
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
639
835
|
}
|
|
640
836
|
};
|
|
641
837
|
}
|
|
@@ -670,6 +866,7 @@ __export(index_exports, {
|
|
|
670
866
|
ApprovalsServicePlugin: () => ApprovalsServicePlugin,
|
|
671
867
|
SysApprovalAction: () => SysApprovalAction,
|
|
672
868
|
SysApprovalApprover: () => SysApprovalApprover,
|
|
869
|
+
SysApprovalDelegation: () => SysApprovalDelegation,
|
|
673
870
|
SysApprovalRequest: () => SysApprovalRequest,
|
|
674
871
|
registerApprovalNode: () => registerApprovalNode
|
|
675
872
|
});
|
|
@@ -683,7 +880,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
683
880
|
pluralLabel: "Approval Requests",
|
|
684
881
|
icon: "inbox",
|
|
685
882
|
isSystem: true,
|
|
686
|
-
managedBy: "
|
|
883
|
+
managedBy: "engine-owned",
|
|
687
884
|
description: "Live approval instance tracked per submission",
|
|
688
885
|
displayNameField: "id",
|
|
689
886
|
nameField: "id",
|
|
@@ -864,7 +1061,172 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
|
|
|
864
1061
|
// human-readable CSV source of truth only.
|
|
865
1062
|
{ fields: ["status", "updated_at"] },
|
|
866
1063
|
{ fields: ["submitter_id", "status"] }
|
|
867
|
-
]
|
|
1064
|
+
],
|
|
1065
|
+
// Server-declared decision actions (objectui#2678 P2-4). The console's
|
|
1066
|
+
// generic action runtime renders and executes these wherever this object is
|
|
1067
|
+
// surfaced — the approvals inbox included — so new decision capabilities
|
|
1068
|
+
// (and their params) ship as metadata, not as hand-written buttons. Each
|
|
1069
|
+
// targets the existing approvals REST route; `{id}` resolves from the row
|
|
1070
|
+
// and `actorId` defaults to the caller server-side. The service remains the
|
|
1071
|
+
// authority on who may act; `visible` gates on the server-computed
|
|
1072
|
+
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
1073
|
+
// (the caller is a current pending approver — same check the service
|
|
1074
|
+
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
1075
|
+
// submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
|
|
1076
|
+
// getRequest/listRequests; where it is absent the predicate fails closed.
|
|
1077
|
+
actions: [
|
|
1078
|
+
{
|
|
1079
|
+
name: "approval_approve",
|
|
1080
|
+
label: "Approve",
|
|
1081
|
+
icon: "check-circle",
|
|
1082
|
+
type: "api",
|
|
1083
|
+
method: "POST",
|
|
1084
|
+
target: "/api/v1/approvals/requests/{id}/approve",
|
|
1085
|
+
params: [
|
|
1086
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1087
|
+
// Decision attachments (#3266). The console renders `type:'file'` params
|
|
1088
|
+
// through the shared upload widget and POSTs the resolved `attachments:
|
|
1089
|
+
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
1090
|
+
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1091
|
+
],
|
|
1092
|
+
visible: "record.viewer.can_act",
|
|
1093
|
+
locations: ["record_section", "list_item"],
|
|
1094
|
+
successMessage: "Approved.",
|
|
1095
|
+
refreshAfter: true
|
|
1096
|
+
},
|
|
1097
|
+
{
|
|
1098
|
+
name: "approval_reject",
|
|
1099
|
+
label: "Reject",
|
|
1100
|
+
icon: "x-circle",
|
|
1101
|
+
type: "api",
|
|
1102
|
+
method: "POST",
|
|
1103
|
+
target: "/api/v1/approvals/requests/{id}/reject",
|
|
1104
|
+
params: [
|
|
1105
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false },
|
|
1106
|
+
{ name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
|
|
1107
|
+
],
|
|
1108
|
+
visible: "record.viewer.can_act",
|
|
1109
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
1110
|
+
locations: ["record_section", "list_item"],
|
|
1111
|
+
successMessage: "Rejected.",
|
|
1112
|
+
refreshAfter: true
|
|
1113
|
+
},
|
|
1114
|
+
{
|
|
1115
|
+
name: "approval_reassign",
|
|
1116
|
+
label: "Reassign",
|
|
1117
|
+
icon: "arrow-right-left",
|
|
1118
|
+
type: "api",
|
|
1119
|
+
method: "POST",
|
|
1120
|
+
target: "/api/v1/approvals/requests/{id}/reassign",
|
|
1121
|
+
params: [
|
|
1122
|
+
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
|
|
1123
|
+
// the console resolves its lookup config (`reference_to: sys_user`) so the
|
|
1124
|
+
// dialog renders a real user picker, while `name: 'to'` overrides the
|
|
1125
|
+
// request-body key to the `to` the reassign route expects. This is a
|
|
1126
|
+
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
|
|
1127
|
+
{ field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
|
|
1128
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1129
|
+
],
|
|
1130
|
+
visible: "record.viewer.can_act",
|
|
1131
|
+
locations: ["record_section"],
|
|
1132
|
+
successMessage: "Reassigned.",
|
|
1133
|
+
refreshAfter: true
|
|
1134
|
+
},
|
|
1135
|
+
// ── Approver secondary decisions ────────────────────────────────
|
|
1136
|
+
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
1137
|
+
// actions, so `visible` gates on `record.viewer.can_act` (a current pending
|
|
1138
|
+
// approver) — same as approve/reject. The service stays the authority.
|
|
1139
|
+
{
|
|
1140
|
+
name: "approval_send_back",
|
|
1141
|
+
label: "Send back",
|
|
1142
|
+
icon: "corner-up-left",
|
|
1143
|
+
type: "api",
|
|
1144
|
+
method: "POST",
|
|
1145
|
+
target: "/api/v1/approvals/requests/{id}/revise",
|
|
1146
|
+
params: [
|
|
1147
|
+
{ name: "comment", label: "Reason", type: "textarea", required: false }
|
|
1148
|
+
],
|
|
1149
|
+
visible: "record.viewer.can_act",
|
|
1150
|
+
locations: ["record_section"],
|
|
1151
|
+
successMessage: "Sent back for revision.",
|
|
1152
|
+
refreshAfter: true
|
|
1153
|
+
},
|
|
1154
|
+
{
|
|
1155
|
+
name: "approval_request_info",
|
|
1156
|
+
label: "Request info",
|
|
1157
|
+
icon: "help-circle",
|
|
1158
|
+
type: "api",
|
|
1159
|
+
method: "POST",
|
|
1160
|
+
target: "/api/v1/approvals/requests/{id}/request-info",
|
|
1161
|
+
params: [
|
|
1162
|
+
{ name: "comment", label: "What do you need?", type: "textarea", required: true }
|
|
1163
|
+
],
|
|
1164
|
+
visible: "record.viewer.can_act",
|
|
1165
|
+
locations: ["record_section"],
|
|
1166
|
+
successMessage: "Information requested.",
|
|
1167
|
+
refreshAfter: true
|
|
1168
|
+
},
|
|
1169
|
+
// ── Submitter continuity actions ────────────────────────────────
|
|
1170
|
+
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
1171
|
+
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
1172
|
+
// (server-computed on the current viewer). The service re-checks ownership;
|
|
1173
|
+
// the predicate keeps a non-submitter from ever seeing a button they cannot
|
|
1174
|
+
// use.
|
|
1175
|
+
{
|
|
1176
|
+
name: "approval_remind",
|
|
1177
|
+
label: "Send reminder",
|
|
1178
|
+
icon: "bell-ring",
|
|
1179
|
+
type: "api",
|
|
1180
|
+
method: "POST",
|
|
1181
|
+
target: "/api/v1/approvals/requests/{id}/remind",
|
|
1182
|
+
params: [
|
|
1183
|
+
{ name: "comment", label: "Note", type: "textarea", required: false }
|
|
1184
|
+
],
|
|
1185
|
+
visible: 'record.status == "pending" && record.viewer.is_submitter',
|
|
1186
|
+
locations: ["record_section"],
|
|
1187
|
+
successMessage: "Reminder sent.",
|
|
1188
|
+
refreshAfter: true
|
|
1189
|
+
},
|
|
1190
|
+
{
|
|
1191
|
+
name: "approval_recall",
|
|
1192
|
+
label: "Recall",
|
|
1193
|
+
icon: "undo-2",
|
|
1194
|
+
type: "api",
|
|
1195
|
+
method: "POST",
|
|
1196
|
+
target: "/api/v1/approvals/requests/{id}/recall",
|
|
1197
|
+
params: [
|
|
1198
|
+
{ name: "comment", label: "Comment", type: "textarea", required: false }
|
|
1199
|
+
],
|
|
1200
|
+
// Recall applies while the request is live for the submitter — pending
|
|
1201
|
+
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
1202
|
+
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
1203
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
1204
|
+
locations: ["record_section"],
|
|
1205
|
+
successMessage: "Recalled.",
|
|
1206
|
+
refreshAfter: true
|
|
1207
|
+
},
|
|
1208
|
+
{
|
|
1209
|
+
name: "approval_resubmit",
|
|
1210
|
+
label: "Resubmit",
|
|
1211
|
+
icon: "refresh-cw",
|
|
1212
|
+
type: "api",
|
|
1213
|
+
method: "POST",
|
|
1214
|
+
target: "/api/v1/approvals/requests/{id}/resubmit",
|
|
1215
|
+
params: [
|
|
1216
|
+
{ name: "comment", label: "What changed?", type: "textarea", required: false }
|
|
1217
|
+
],
|
|
1218
|
+
visible: 'record.status == "returned" && record.viewer.is_submitter',
|
|
1219
|
+
locations: ["record_section"],
|
|
1220
|
+
successMessage: "Resubmitted.",
|
|
1221
|
+
refreshAfter: true
|
|
1222
|
+
}
|
|
1223
|
+
],
|
|
1224
|
+
enable: {
|
|
1225
|
+
// [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
|
|
1226
|
+
// (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
|
|
1227
|
+
// generic CRUD. Reads stay open.
|
|
1228
|
+
apiMethods: ["get", "list"]
|
|
1229
|
+
}
|
|
868
1230
|
});
|
|
869
1231
|
|
|
870
1232
|
// src/sys-approval-action.object.ts
|
|
@@ -942,8 +1304,9 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
942
1304
|
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
|
|
943
1305
|
// remind / request_info / comment are thread interactions — they never
|
|
944
1306
|
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
|
|
945
|
-
// revision and the later resubmission.
|
|
946
|
-
|
|
1307
|
+
// revision and the later resubmission. ooo_substitute (#1322 M1) is a
|
|
1308
|
+
// system-recorded reroute of an out-of-office approver — no flow movement.
|
|
1309
|
+
["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
|
|
947
1310
|
{
|
|
948
1311
|
label: "Action",
|
|
949
1312
|
required: true,
|
|
@@ -956,6 +1319,13 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
956
1319
|
group: "Action"
|
|
957
1320
|
}),
|
|
958
1321
|
comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
|
|
1322
|
+
attachments: import_data2.Field.file({
|
|
1323
|
+
label: "Attachments",
|
|
1324
|
+
required: false,
|
|
1325
|
+
multiple: true,
|
|
1326
|
+
group: "Action",
|
|
1327
|
+
description: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
|
|
1328
|
+
}),
|
|
959
1329
|
created_at: import_data2.Field.datetime({
|
|
960
1330
|
label: "Created At",
|
|
961
1331
|
required: true,
|
|
@@ -967,7 +1337,12 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
|
|
|
967
1337
|
indexes: [
|
|
968
1338
|
{ fields: ["request_id", "created_at"] },
|
|
969
1339
|
{ fields: ["request_id", "step_index", "action"] }
|
|
970
|
-
]
|
|
1340
|
+
],
|
|
1341
|
+
enable: {
|
|
1342
|
+
// [ADR-0103] Engine-owned append-only decision log: appended by the approval
|
|
1343
|
+
// engine (SYSTEM_CTX). Reads stay open.
|
|
1344
|
+
apiMethods: ["get", "list"]
|
|
1345
|
+
}
|
|
971
1346
|
});
|
|
972
1347
|
|
|
973
1348
|
// src/sys-approval-approver.object.ts
|
|
@@ -978,7 +1353,7 @@ var SysApprovalApprover = import_data3.ObjectSchema.create({
|
|
|
978
1353
|
pluralLabel: "Approval Approvers",
|
|
979
1354
|
icon: "users",
|
|
980
1355
|
isSystem: true,
|
|
981
|
-
managedBy: "
|
|
1356
|
+
managedBy: "engine-owned",
|
|
982
1357
|
description: "Normalized pending-approver rows for indexed inbox queries",
|
|
983
1358
|
displayNameField: "id",
|
|
984
1359
|
nameField: "id",
|
|
@@ -1018,18 +1393,125 @@ var SysApprovalApprover = import_data3.ObjectSchema.create({
|
|
|
1018
1393
|
{ fields: ["approver", "organization_id"] },
|
|
1019
1394
|
// Sync path: rewrite all rows of one request on each approver-set change.
|
|
1020
1395
|
{ fields: ["request_id"] }
|
|
1021
|
-
]
|
|
1396
|
+
],
|
|
1397
|
+
enable: {
|
|
1398
|
+
// [ADR-0103] Engine-owned: approver rows are rewritten by the approval
|
|
1399
|
+
// engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
|
|
1400
|
+
apiMethods: ["get", "list"]
|
|
1401
|
+
}
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
// src/sys-approval-delegation.object.ts
|
|
1405
|
+
var import_data4 = require("@objectstack/spec/data");
|
|
1406
|
+
var SysApprovalDelegation = import_data4.ObjectSchema.create({
|
|
1407
|
+
name: "sys_approval_delegation",
|
|
1408
|
+
label: "Approval Delegation",
|
|
1409
|
+
pluralLabel: "Approval Delegations",
|
|
1410
|
+
icon: "user-clock",
|
|
1411
|
+
isSystem: true,
|
|
1412
|
+
managedBy: "system",
|
|
1413
|
+
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
|
|
1414
|
+
// authors their own out-of-office delegation. Affordance only (matches the
|
|
1415
|
+
// full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
|
|
1416
|
+
// keeps the system write guard from rejecting the self-service write.
|
|
1417
|
+
userActions: { create: true, edit: true, delete: true },
|
|
1418
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
1419
|
+
titleFormat: "{delegator_id} \u2192 {delegate_id}",
|
|
1420
|
+
highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
|
|
1421
|
+
listViews: {
|
|
1422
|
+
active: {
|
|
1423
|
+
type: "grid",
|
|
1424
|
+
name: "active",
|
|
1425
|
+
label: "Active",
|
|
1426
|
+
data: { provider: "object", object: "sys_approval_delegation" },
|
|
1427
|
+
columns: ["delegator_id", "delegate_id", "valid_from", "valid_until", "reason"],
|
|
1428
|
+
sort: [{ field: "valid_until", order: "asc" }],
|
|
1429
|
+
pagination: { pageSize: 50 },
|
|
1430
|
+
emptyState: {
|
|
1431
|
+
title: "No delegations",
|
|
1432
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
},
|
|
1436
|
+
fields: {
|
|
1437
|
+
id: import_data4.Field.text({ label: "Delegation ID", required: true, readonly: true, group: "System" }),
|
|
1438
|
+
delegator_id: import_data4.Field.lookup("sys_user", {
|
|
1439
|
+
label: "Delegator",
|
|
1440
|
+
required: true,
|
|
1441
|
+
group: "Delegation",
|
|
1442
|
+
description: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
1443
|
+
}),
|
|
1444
|
+
delegate_id: import_data4.Field.lookup("sys_user", {
|
|
1445
|
+
label: "Delegate",
|
|
1446
|
+
required: true,
|
|
1447
|
+
group: "Delegation",
|
|
1448
|
+
description: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
1449
|
+
}),
|
|
1450
|
+
valid_from: import_data4.Field.datetime({
|
|
1451
|
+
label: "Valid From",
|
|
1452
|
+
required: false,
|
|
1453
|
+
group: "Delegation",
|
|
1454
|
+
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."
|
|
1455
|
+
}),
|
|
1456
|
+
valid_until: import_data4.Field.datetime({
|
|
1457
|
+
label: "Valid Until",
|
|
1458
|
+
required: false,
|
|
1459
|
+
group: "Delegation",
|
|
1460
|
+
description: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
1461
|
+
}),
|
|
1462
|
+
reason: import_data4.Field.text({
|
|
1463
|
+
label: "Reason",
|
|
1464
|
+
required: false,
|
|
1465
|
+
maxLength: 500,
|
|
1466
|
+
group: "Delegation",
|
|
1467
|
+
description: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
|
|
1468
|
+
}),
|
|
1469
|
+
organization_id: import_data4.Field.lookup("sys_organization", {
|
|
1470
|
+
label: "Organization",
|
|
1471
|
+
required: false,
|
|
1472
|
+
group: "System",
|
|
1473
|
+
description: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
1474
|
+
}),
|
|
1475
|
+
created_at: import_data4.Field.datetime({
|
|
1476
|
+
label: "Created At",
|
|
1477
|
+
defaultValue: "NOW()",
|
|
1478
|
+
readonly: true,
|
|
1479
|
+
group: "System"
|
|
1480
|
+
}),
|
|
1481
|
+
updated_at: import_data4.Field.datetime({
|
|
1482
|
+
label: "Updated At",
|
|
1483
|
+
defaultValue: "NOW()",
|
|
1484
|
+
readonly: true,
|
|
1485
|
+
group: "System"
|
|
1486
|
+
})
|
|
1487
|
+
},
|
|
1488
|
+
indexes: [
|
|
1489
|
+
// Resolution-time lookup: "active delegations for this delegator".
|
|
1490
|
+
{ fields: ["delegator_id", "organization_id"] },
|
|
1491
|
+
{ fields: ["delegate_id"] },
|
|
1492
|
+
{ fields: ["valid_until"] }
|
|
1493
|
+
],
|
|
1494
|
+
enable: {
|
|
1495
|
+
trackHistory: true,
|
|
1496
|
+
searchable: true,
|
|
1497
|
+
apiEnabled: true,
|
|
1498
|
+
apiMethods: ["get", "list", "create", "update", "delete"],
|
|
1499
|
+
trash: true,
|
|
1500
|
+
mru: false
|
|
1501
|
+
}
|
|
1022
1502
|
});
|
|
1023
1503
|
|
|
1024
1504
|
// src/approval-service.ts
|
|
1025
1505
|
var import_node_crypto = require("crypto");
|
|
1026
1506
|
var import_automation = require("@objectstack/spec/automation");
|
|
1507
|
+
var import_core = require("@objectstack/core");
|
|
1027
1508
|
var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
|
|
1028
1509
|
var ESCALATION_JOB_NAME = "approvals-sla-escalation";
|
|
1029
1510
|
var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1030
1511
|
var SLA_ACTOR_ID = "system:sla";
|
|
1031
1512
|
var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
|
|
1032
1513
|
var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
|
|
1514
|
+
var OOO_MAX_CHAIN = 8;
|
|
1033
1515
|
function uid(prefix) {
|
|
1034
1516
|
const g = globalThis;
|
|
1035
1517
|
if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
|
|
@@ -1102,6 +1584,10 @@ function rowFromAction(row) {
|
|
|
1102
1584
|
action: row.action,
|
|
1103
1585
|
actor_id: row.actor_id ?? void 0,
|
|
1104
1586
|
comment: row.comment ?? void 0,
|
|
1587
|
+
// Decision attachments (#3266). The column shipped in #3268 but this
|
|
1588
|
+
// contract mapping didn't — the raw engine row carried the fileIds while
|
|
1589
|
+
// every consumer of listActions saw none (caught by browser verification).
|
|
1590
|
+
attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
|
|
1105
1591
|
created_at: row.created_at ?? void 0
|
|
1106
1592
|
};
|
|
1107
1593
|
}
|
|
@@ -1126,8 +1612,12 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1126
1612
|
async notify(input) {
|
|
1127
1613
|
const audience = input.audience.filter((a) => a && !a.includes(":"));
|
|
1128
1614
|
if (!this.messaging || !audience.length) return 0;
|
|
1615
|
+
let payload = input.payload;
|
|
1616
|
+
if (payload?.actionUrl === "/system/approvals" && input.source?.object === "sys_approval_request" && input.source.id) {
|
|
1617
|
+
payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
|
|
1618
|
+
}
|
|
1129
1619
|
try {
|
|
1130
|
-
await this.messaging.emit({ severity: "info", ...input, audience });
|
|
1620
|
+
await this.messaging.emit({ severity: "info", ...input, payload, audience });
|
|
1131
1621
|
return audience.length;
|
|
1132
1622
|
} catch (err) {
|
|
1133
1623
|
this.logger?.warn?.("[approvals] notification failed", {
|
|
@@ -1152,10 +1642,10 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1152
1642
|
}
|
|
1153
1643
|
/**
|
|
1154
1644
|
* Expand the approvers on an Approval node into user IDs by querying the
|
|
1155
|
-
* graph tables for `team:` / `department:` / `position:` /
|
|
1156
|
-
* `manager:` approver types. Falls back to a
|
|
1157
|
-
* (`type:value`) when graph lookups produce nothing — so
|
|
1158
|
-
* and flows that rely on substring matching keep working.
|
|
1645
|
+
* graph tables for `team:` / `department:` / `position:` /
|
|
1646
|
+
* `org_membership_level:` / `manager:` approver types. Falls back to a
|
|
1647
|
+
* prefixed literal (`type:value`) when graph lookups produce nothing — so
|
|
1648
|
+
* existing fixtures and flows that rely on substring matching keep working.
|
|
1159
1649
|
*
|
|
1160
1650
|
* **Graph semantics:**
|
|
1161
1651
|
* - `team` → flat members of `sys_team` (better-auth; no BFS)
|
|
@@ -1163,66 +1653,90 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1163
1653
|
* → members of every descendant via `sys_business_unit_member`
|
|
1164
1654
|
* - `position` → holders via `sys_user_position` ∪ `sys_member.role`
|
|
1165
1655
|
* transition source (ADR-0090 D3 / ADR-0057 D4)
|
|
1166
|
-
* - `
|
|
1656
|
+
* - `org_membership_level`
|
|
1657
|
+
* → users with `sys_member.role = value` in tenant — the
|
|
1167
1658
|
* better-auth MEMBERSHIP TIER (owner/admin/member), not a
|
|
1168
1659
|
* position; author `position` for org positions
|
|
1169
1660
|
* - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
|
|
1170
1661
|
* - `field` → literal user id stored in `record[value]`
|
|
1171
1662
|
* - `user` → literal value
|
|
1663
|
+
*
|
|
1664
|
+
* `role` is accepted as the deprecated spelling of `org_membership_level`
|
|
1665
|
+
* (ADR-0090 D3) for one window: it resolves identically and logs a warning.
|
|
1666
|
+
*
|
|
1667
|
+
* **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
|
|
1668
|
+
* resolve to a specific person (`user` / `field` / `manager`) — are passed
|
|
1669
|
+
* through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
|
|
1670
|
+
* an active delegate when the resolved user has declared OOO. Group/graph
|
|
1671
|
+
* approvers (`team` / `department` / `position` / `org_membership_level`) are
|
|
1672
|
+
* left untouched: a group still has its other members, and position-routed
|
|
1673
|
+
* leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
|
|
1674
|
+
* `opts.substitutions` collector to record the hops for audit + notification.
|
|
1172
1675
|
*/
|
|
1173
|
-
async expandApprovers(step, record, organizationId) {
|
|
1676
|
+
async expandApprovers(step, record, organizationId, opts) {
|
|
1677
|
+
var _a;
|
|
1174
1678
|
if (!step || !Array.isArray(step.approvers)) return [];
|
|
1679
|
+
const now = opts?.now ?? this.clock.now().getTime();
|
|
1175
1680
|
const out = [];
|
|
1176
|
-
|
|
1681
|
+
const specs = step.approvers;
|
|
1682
|
+
for (let idx = 0; idx < specs.length; idx++) {
|
|
1683
|
+
const a = specs[idx];
|
|
1177
1684
|
if (!a) continue;
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1685
|
+
const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
|
|
1686
|
+
const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
|
|
1687
|
+
for (const u of ids) {
|
|
1688
|
+
if (!u) continue;
|
|
1689
|
+
out.push(u);
|
|
1690
|
+
if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
|
|
1181
1691
|
}
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1692
|
+
}
|
|
1693
|
+
return out.filter(Boolean);
|
|
1694
|
+
}
|
|
1695
|
+
/**
|
|
1696
|
+
* Resolve ONE approver spec to concrete approver identities, applying OOO
|
|
1697
|
+
* substitution (#1322) to individually-routed types. Extracted from
|
|
1698
|
+
* {@link ApprovalService.expandApprovers} so the caller can tag each spec's
|
|
1699
|
+
* resolved ids with a group (#3266) without duplicating the resolution logic.
|
|
1700
|
+
* Returns the `type:value` literal as a single-element fallback when a graph
|
|
1701
|
+
* lookup yields nothing — same behaviour as before the extraction.
|
|
1702
|
+
*/
|
|
1703
|
+
async resolveApproverSpec(a, record, organizationId, now, substitutions) {
|
|
1704
|
+
const type = (0, import_automation.canonicalApproverType)(String(a.type));
|
|
1705
|
+
if (type !== a.type) {
|
|
1706
|
+
this.logger?.warn?.(
|
|
1707
|
+
`[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) \u2014 author '${type}' instead`,
|
|
1708
|
+
{ deprecated: a.type, canonical: type }
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
if (type === "user") {
|
|
1712
|
+
return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
|
|
1713
|
+
}
|
|
1714
|
+
if (type === "field" && record) {
|
|
1715
|
+
return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
|
|
1716
|
+
}
|
|
1717
|
+
try {
|
|
1718
|
+
if (type === "team") {
|
|
1719
|
+
const users = await this.expandTeamUsers(String(a.value));
|
|
1720
|
+
if (users.length) return users;
|
|
1721
|
+
} else if (type === "department" || type === "business_unit" || type === "bu") {
|
|
1722
|
+
const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
|
|
1723
|
+
if (users.length) return users;
|
|
1724
|
+
} else if (type === "position") {
|
|
1725
|
+
const users = await this.expandPositionUsers(String(a.value), organizationId);
|
|
1726
|
+
if (users.length) return users;
|
|
1727
|
+
} else if (type === "org_membership_level") {
|
|
1728
|
+
const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
|
|
1729
|
+
if (users.length) return users;
|
|
1730
|
+
} else if (type === "manager" && record) {
|
|
1731
|
+
const subject = record[a.value] ?? record.owner_id;
|
|
1732
|
+
if (subject) {
|
|
1733
|
+
const mgr = await this.lookupManager(String(subject));
|
|
1734
|
+
if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
|
|
1220
1735
|
}
|
|
1221
|
-
} catch {
|
|
1222
1736
|
}
|
|
1223
|
-
|
|
1737
|
+
} catch {
|
|
1224
1738
|
}
|
|
1225
|
-
return
|
|
1739
|
+
return [`${a.type}:${a.value}`];
|
|
1226
1740
|
}
|
|
1227
1741
|
/** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
|
|
1228
1742
|
async expandTeamUsers(teamId) {
|
|
@@ -1314,13 +1828,18 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1314
1828
|
}
|
|
1315
1829
|
} catch {
|
|
1316
1830
|
}
|
|
1317
|
-
for (const uid2 of await this.
|
|
1831
|
+
for (const uid2 of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid2);
|
|
1318
1832
|
return Array.from(users);
|
|
1319
1833
|
}
|
|
1320
|
-
/**
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1834
|
+
/**
|
|
1835
|
+
* better-auth org-membership tier (`sys_member.role`: owner/admin/member) —
|
|
1836
|
+
* NOT positions. Named for the projection (`org_membership_level`, ADR-0057
|
|
1837
|
+
* D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
|
|
1838
|
+
* and stays, the platform-facing word does not.
|
|
1839
|
+
*/
|
|
1840
|
+
async expandMembershipTierUsers(tier, organizationId) {
|
|
1841
|
+
if (!tier) return [];
|
|
1842
|
+
const filter = { role: tier };
|
|
1324
1843
|
if (organizationId) filter.organization_id = organizationId;
|
|
1325
1844
|
let rows = [];
|
|
1326
1845
|
try {
|
|
@@ -1344,6 +1863,62 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1344
1863
|
return null;
|
|
1345
1864
|
}
|
|
1346
1865
|
}
|
|
1866
|
+
/**
|
|
1867
|
+
* Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
|
|
1868
|
+
* id, follow any active `sys_approval_delegation` chain and return the id the
|
|
1869
|
+
* slot should actually go to — the delegate acts under their own identity, so
|
|
1870
|
+
* no impersonation is involved. Returns `[userId]` unchanged when there is no
|
|
1871
|
+
* active delegation. Each hop is appended to `collector` (when supplied) so
|
|
1872
|
+
* the caller can audit + notify (M4).
|
|
1873
|
+
*
|
|
1874
|
+
* The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
|
|
1875
|
+
* stops on a self-reference or a cycle, so a mis-declared loop degrades to the
|
|
1876
|
+
* last reachable delegate rather than hanging.
|
|
1877
|
+
*/
|
|
1878
|
+
async applyOooDelegation(userId, now, organizationId, collector) {
|
|
1879
|
+
const start = String(userId ?? "").trim();
|
|
1880
|
+
if (!start) return [];
|
|
1881
|
+
let current = start;
|
|
1882
|
+
const visited = /* @__PURE__ */ new Set([current]);
|
|
1883
|
+
for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
|
|
1884
|
+
const del = await this.lookupActiveDelegation(current, now, organizationId);
|
|
1885
|
+
if (!del) break;
|
|
1886
|
+
const to = String(del.delegate_id ?? "").trim();
|
|
1887
|
+
if (!to || to === current || visited.has(to)) break;
|
|
1888
|
+
collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
|
|
1889
|
+
visited.add(to);
|
|
1890
|
+
current = to;
|
|
1891
|
+
}
|
|
1892
|
+
return [current];
|
|
1893
|
+
}
|
|
1894
|
+
/**
|
|
1895
|
+
* The active OOO delegation for a delegator at `now`, or null. Validity is the
|
|
1896
|
+
* shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
|
|
1897
|
+
* resolution time — never by a background job. When several rows are active,
|
|
1898
|
+
* the one expiring soonest wins (the most specific coverage window).
|
|
1899
|
+
*/
|
|
1900
|
+
async lookupActiveDelegation(delegatorId, now, organizationId) {
|
|
1901
|
+
if (!delegatorId) return null;
|
|
1902
|
+
let rows = [];
|
|
1903
|
+
try {
|
|
1904
|
+
rows = await this.engine.find("sys_approval_delegation", {
|
|
1905
|
+
filter: { delegator_id: delegatorId },
|
|
1906
|
+
fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
|
|
1907
|
+
limit: 50,
|
|
1908
|
+
context: SYSTEM_CTX
|
|
1909
|
+
});
|
|
1910
|
+
} catch {
|
|
1911
|
+
return null;
|
|
1912
|
+
}
|
|
1913
|
+
const active = (rows ?? []).filter((r) => (0, import_core.isGrantActive)(r, now) && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
|
|
1914
|
+
if (!active.length) return null;
|
|
1915
|
+
active.sort((a, b) => {
|
|
1916
|
+
const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1917
|
+
const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
|
|
1918
|
+
return au - bu;
|
|
1919
|
+
});
|
|
1920
|
+
return active[0];
|
|
1921
|
+
}
|
|
1347
1922
|
/** Mirror a request status onto a business-object field, if configured. */
|
|
1348
1923
|
async mirrorStatusField(object, recordId, field, status) {
|
|
1349
1924
|
try {
|
|
@@ -1377,13 +1952,24 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1377
1952
|
throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
|
|
1378
1953
|
}
|
|
1379
1954
|
const ctxOrg = context?.organizationId ?? context?.tenantId ?? input.organizationId ?? null;
|
|
1380
|
-
const
|
|
1381
|
-
const
|
|
1955
|
+
const nowDate = this.clock.now();
|
|
1956
|
+
const substitutions = [];
|
|
1957
|
+
const groups = {};
|
|
1958
|
+
const approvers = await this.expandApprovers(
|
|
1959
|
+
{ approvers: input.config.approvers },
|
|
1960
|
+
input.record,
|
|
1961
|
+
ctxOrg,
|
|
1962
|
+
{ now: nowDate.getTime(), substitutions, groups }
|
|
1963
|
+
);
|
|
1964
|
+
const now = nowDate.toISOString();
|
|
1382
1965
|
const id = uid("areq");
|
|
1383
1966
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1384
1967
|
const configSnapshot = { ...input.config };
|
|
1385
1968
|
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1386
1969
|
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1970
|
+
if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
|
|
1971
|
+
configSnapshot.__approverGroups = groups;
|
|
1972
|
+
}
|
|
1387
1973
|
try {
|
|
1388
1974
|
const prior = await this.engine.find("sys_approval_request", {
|
|
1389
1975
|
where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
|
|
@@ -1425,17 +2011,83 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1425
2011
|
comment: null,
|
|
1426
2012
|
created_at: now
|
|
1427
2013
|
}, { context: SYSTEM_CTX });
|
|
2014
|
+
for (const sub of substitutions) {
|
|
2015
|
+
await this.engine.insert("sys_approval_action", {
|
|
2016
|
+
id: uid("aact"),
|
|
2017
|
+
request_id: id,
|
|
2018
|
+
organization_id: ctxOrg,
|
|
2019
|
+
step_name: input.nodeId,
|
|
2020
|
+
step_index: 0,
|
|
2021
|
+
action: "ooo_substitute",
|
|
2022
|
+
actor_id: null,
|
|
2023
|
+
comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
|
|
2024
|
+
created_at: now
|
|
2025
|
+
}, { context: SYSTEM_CTX });
|
|
2026
|
+
await this.notify({
|
|
2027
|
+
topic: "approval.ooo_substituted",
|
|
2028
|
+
audience: [sub.to],
|
|
2029
|
+
source: { object: "sys_approval_request", id },
|
|
2030
|
+
dedupKey: `approval-ooo-${id}-${sub.to}`,
|
|
2031
|
+
payload: {
|
|
2032
|
+
title: "Approval routed to you (out-of-office cover)",
|
|
2033
|
+
message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
|
|
2034
|
+
actionUrl: "/system/approvals"
|
|
2035
|
+
}
|
|
2036
|
+
});
|
|
2037
|
+
await this.notify({
|
|
2038
|
+
topic: "approval.ooo_skipped",
|
|
2039
|
+
audience: [sub.from],
|
|
2040
|
+
source: { object: "sys_approval_request", id },
|
|
2041
|
+
dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
|
|
2042
|
+
payload: {
|
|
2043
|
+
title: "Approval routed to your delegate",
|
|
2044
|
+
message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
|
|
2045
|
+
actionUrl: "/system/approvals"
|
|
2046
|
+
}
|
|
2047
|
+
});
|
|
2048
|
+
}
|
|
1428
2049
|
if (input.config.approvalStatusField) {
|
|
1429
2050
|
await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
|
|
1430
2051
|
}
|
|
1431
2052
|
return rowFromRequest(row);
|
|
1432
2053
|
}
|
|
1433
2054
|
/**
|
|
1434
|
-
*
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
*
|
|
2055
|
+
* True when the approve tally satisfies the node's `behavior` (#3266):
|
|
2056
|
+
* - `unanimous` — every resolved approver approved.
|
|
2057
|
+
* - `quorum` — at least `minApprovals` distinct approvals (default = all).
|
|
2058
|
+
* - `per_group` — every group reached `minApprovals` approvals (default 1).
|
|
2059
|
+
* Thresholds are clamped to the resolvable count / group size, so a mis-set
|
|
2060
|
+
* value can never deadlock a request.
|
|
2061
|
+
*/
|
|
2062
|
+
isApprovalSatisfied(behavior, config, original, groupMap, approved) {
|
|
2063
|
+
if (behavior === "unanimous") {
|
|
2064
|
+
return original.length > 0 && original.every((a) => approved.has(a));
|
|
2065
|
+
}
|
|
2066
|
+
if (behavior === "quorum") {
|
|
2067
|
+
const n = original.length || 1;
|
|
2068
|
+
const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
|
|
2069
|
+
return approved.size >= need;
|
|
2070
|
+
}
|
|
2071
|
+
if (behavior === "per_group") {
|
|
2072
|
+
const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
|
|
2073
|
+
const size = {};
|
|
2074
|
+
for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
2075
|
+
const groups = Object.keys(size);
|
|
2076
|
+
if (!groups.length) return true;
|
|
2077
|
+
const got = {};
|
|
2078
|
+
for (const a of approved) for (const g of groupMap[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
2079
|
+
return groups.every((g) => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
|
|
2080
|
+
}
|
|
2081
|
+
return true;
|
|
2082
|
+
}
|
|
2083
|
+
/**
|
|
2084
|
+
* Record a decision on a node-driven request. Honours the node's `behavior`
|
|
2085
|
+
* (#3266): `first_response` finalizes on the first approval; `unanimous`,
|
|
2086
|
+
* `quorum`, and `per_group` hold the request open until their tally is met
|
|
2087
|
+
* (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
|
|
2088
|
+
* finalizes the node (one veto). When the request finalizes, returns the
|
|
2089
|
+
* suspended run id + node id so the caller (or {@link ApprovalService.decide})
|
|
2090
|
+
* can resume the flow down the matching branch.
|
|
1439
2091
|
*/
|
|
1440
2092
|
async decideNode(requestId, input, context) {
|
|
1441
2093
|
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
@@ -1469,22 +2121,33 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1469
2121
|
action: input.decision,
|
|
1470
2122
|
actor_id: input.actorId,
|
|
1471
2123
|
comment: input.comment ?? null,
|
|
2124
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
1472
2125
|
created_at: now
|
|
1473
2126
|
}, { context: SYSTEM_CTX });
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
{ approvers: config.approvers },
|
|
1477
|
-
parseJson(raw.payload_json, void 0),
|
|
1478
|
-
org
|
|
1479
|
-
);
|
|
2127
|
+
const behavior = config.behavior ?? "first_response";
|
|
2128
|
+
if (input.decision === "approve" && behavior !== "first_response") {
|
|
1480
2129
|
const acts = await this.engine.find("sys_approval_action", {
|
|
1481
2130
|
where: { request_id: requestId, step_index: 0, action: "approve" },
|
|
1482
|
-
limit:
|
|
2131
|
+
limit: 1e3,
|
|
1483
2132
|
context: SYSTEM_CTX
|
|
1484
2133
|
});
|
|
1485
2134
|
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
1486
|
-
const
|
|
1487
|
-
|
|
2135
|
+
const snapshotGroups = config.__approverGroups;
|
|
2136
|
+
let original;
|
|
2137
|
+
let groupMap;
|
|
2138
|
+
if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
|
|
2139
|
+
groupMap = snapshotGroups;
|
|
2140
|
+
original = Object.keys(snapshotGroups);
|
|
2141
|
+
} else {
|
|
2142
|
+
original = await this.expandApprovers(
|
|
2143
|
+
{ approvers: config.approvers },
|
|
2144
|
+
parseJson(raw.payload_json, void 0),
|
|
2145
|
+
org
|
|
2146
|
+
);
|
|
2147
|
+
groupMap = {};
|
|
2148
|
+
}
|
|
2149
|
+
if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
|
|
2150
|
+
const stillPending = original.filter((a) => !approved.has(a));
|
|
1488
2151
|
await this.engine.update("sys_approval_request", {
|
|
1489
2152
|
id: requestId,
|
|
1490
2153
|
pending_approvers: stillPending.join(","),
|
|
@@ -1918,10 +2581,22 @@ var _ApprovalService = class _ApprovalService {
|
|
|
1918
2581
|
comment: input.comment ?? `${from} \u2192 ${to}`,
|
|
1919
2582
|
created_at: now
|
|
1920
2583
|
}, { context: SYSTEM_CTX });
|
|
2584
|
+
let configPatch = {};
|
|
2585
|
+
try {
|
|
2586
|
+
const cfg = parseJson(raw.node_config_json, null);
|
|
2587
|
+
const groups = cfg?.__approverGroups;
|
|
2588
|
+
if (groups && groups[from] && !groups[to]) {
|
|
2589
|
+
groups[to] = groups[from];
|
|
2590
|
+
delete groups[from];
|
|
2591
|
+
configPatch = { node_config_json: JSON.stringify(cfg) };
|
|
2592
|
+
}
|
|
2593
|
+
} catch {
|
|
2594
|
+
}
|
|
1921
2595
|
await this.engine.update("sys_approval_request", {
|
|
1922
2596
|
id: requestId,
|
|
1923
2597
|
pending_approvers: next.join(","),
|
|
1924
|
-
updated_at: now
|
|
2598
|
+
updated_at: now,
|
|
2599
|
+
...configPatch
|
|
1925
2600
|
}, { context: SYSTEM_CTX });
|
|
1926
2601
|
await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
|
|
1927
2602
|
await this.notify({
|
|
@@ -2166,6 +2841,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2166
2841
|
action: "comment",
|
|
2167
2842
|
actor_id: input.actorId,
|
|
2168
2843
|
comment: input.comment.trim(),
|
|
2844
|
+
attachments: input.attachments?.length ? input.attachments : null,
|
|
2169
2845
|
created_at: now
|
|
2170
2846
|
}, { context: SYSTEM_CTX });
|
|
2171
2847
|
const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
|
|
@@ -2683,6 +3359,7 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2683
3359
|
const rows = await this.engine.find("sys_approval_request", findOpts);
|
|
2684
3360
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
2685
3361
|
await this.enrichRows(list);
|
|
3362
|
+
this.attachViewers(list, context);
|
|
2686
3363
|
return list;
|
|
2687
3364
|
}
|
|
2688
3365
|
async countRequests(filter, context) {
|
|
@@ -2723,8 +3400,74 @@ var _ApprovalService = class _ApprovalService {
|
|
|
2723
3400
|
const row = rowFromRequest(rows[0]);
|
|
2724
3401
|
await this.enrichRows([row]);
|
|
2725
3402
|
await this.attachFlowSteps(row);
|
|
3403
|
+
await this.attachDecisionProgress(row, rows[0]);
|
|
3404
|
+
this.attachViewers([row], context);
|
|
2726
3405
|
return row;
|
|
2727
3406
|
}
|
|
3407
|
+
/**
|
|
3408
|
+
* Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
|
|
3409
|
+
* Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
|
|
3410
|
+
* for a PENDING request whose behavior aggregates multiple approvals
|
|
3411
|
+
* (`unanimous` / `quorum` / `per_group`), expose
|
|
3412
|
+
* `decision_progress: { behavior, got, need, groups? }` so any client renders
|
|
3413
|
+
* "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
|
|
3414
|
+
* `first_response` requests carry no progress (one approval finalizes).
|
|
3415
|
+
* Display-only and best-effort — errors leave the row untouched.
|
|
3416
|
+
*/
|
|
3417
|
+
async attachDecisionProgress(row, raw) {
|
|
3418
|
+
try {
|
|
3419
|
+
if (row.status !== "pending") return;
|
|
3420
|
+
const cfg = parseJson(raw.node_config_json, void 0);
|
|
3421
|
+
const behavior = cfg?.behavior ?? "first_response";
|
|
3422
|
+
if (behavior !== "unanimous" && behavior !== "quorum" && behavior !== "per_group") return;
|
|
3423
|
+
const acts = await this.engine.find("sys_approval_action", {
|
|
3424
|
+
where: { request_id: row.id, step_index: 0, action: "approve" },
|
|
3425
|
+
limit: 1e3,
|
|
3426
|
+
context: SYSTEM_CTX
|
|
3427
|
+
});
|
|
3428
|
+
const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
|
|
3429
|
+
const snapshot = cfg?.__approverGroups;
|
|
3430
|
+
const slate = snapshot ? Object.keys(snapshot) : [...approved, ...row.pending_approvers ?? []];
|
|
3431
|
+
const total = slate.length || 1;
|
|
3432
|
+
const progress = { behavior, got: approved.size, need: total };
|
|
3433
|
+
if (behavior === "quorum") {
|
|
3434
|
+
progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
|
|
3435
|
+
} else if (behavior === "per_group" && snapshot) {
|
|
3436
|
+
const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
|
|
3437
|
+
const size = {};
|
|
3438
|
+
for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
|
|
3439
|
+
const got = {};
|
|
3440
|
+
for (const a of approved) for (const g of snapshot[a] ?? []) got[g] = (got[g] ?? 0) + 1;
|
|
3441
|
+
progress.groups = Object.keys(size).sort().map((g) => {
|
|
3442
|
+
const need = Math.min(perGroupNeed, size[g]);
|
|
3443
|
+
return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
|
|
3444
|
+
});
|
|
3445
|
+
progress.got = progress.groups.filter((g) => g.satisfied).length;
|
|
3446
|
+
progress.need = progress.groups.length;
|
|
3447
|
+
}
|
|
3448
|
+
row.decision_progress = progress;
|
|
3449
|
+
} catch {
|
|
3450
|
+
}
|
|
3451
|
+
}
|
|
3452
|
+
/**
|
|
3453
|
+
* Attach the per-viewer capability block (#3310) from the caller's context.
|
|
3454
|
+
* `can_act` mirrors the exact authorization the decision methods enforce — the
|
|
3455
|
+
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
3456
|
+
* still `pending` (position/team/manager approvers are already resolved to
|
|
3457
|
+
* concrete user ids at open time, so a plain membership test is faithful).
|
|
3458
|
+
* `is_submitter` is a straight owner check. System/tokenless contexts get a
|
|
3459
|
+
* both-false block. Cheap + synchronous — safe on list reads.
|
|
3460
|
+
*/
|
|
3461
|
+
attachViewers(rows, context) {
|
|
3462
|
+
const uid2 = context?.userId != null ? String(context.userId) : null;
|
|
3463
|
+
for (const row of rows) {
|
|
3464
|
+
const pending = row.pending_approvers ?? [];
|
|
3465
|
+
row.viewer = {
|
|
3466
|
+
can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
|
|
3467
|
+
is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
|
|
3468
|
+
};
|
|
3469
|
+
}
|
|
3470
|
+
}
|
|
2728
3471
|
/**
|
|
2729
3472
|
* Derive approval-step progress from the owning flow's graph (single-read
|
|
2730
3473
|
* enrichment only — list reads skip it). Walks from the start node
|
|
@@ -2786,26 +3529,26 @@ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
|
|
|
2786
3529
|
var ApprovalService = _ApprovalService;
|
|
2787
3530
|
|
|
2788
3531
|
// src/sys-approval-token.object.ts
|
|
2789
|
-
var
|
|
2790
|
-
var SysApprovalToken =
|
|
3532
|
+
var import_data5 = require("@objectstack/spec/data");
|
|
3533
|
+
var SysApprovalToken = import_data5.ObjectSchema.create({
|
|
2791
3534
|
name: "sys_approval_token",
|
|
2792
3535
|
label: "Approval Action Token",
|
|
2793
3536
|
pluralLabel: "Approval Action Tokens",
|
|
2794
3537
|
icon: "key",
|
|
2795
3538
|
isSystem: true,
|
|
2796
|
-
managedBy: "
|
|
3539
|
+
managedBy: "engine-owned",
|
|
2797
3540
|
description: "Single-use tokens behind actionable approval links",
|
|
2798
3541
|
displayNameField: "id",
|
|
2799
3542
|
nameField: "id",
|
|
2800
3543
|
// [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
2801
3544
|
fields: {
|
|
2802
|
-
id:
|
|
2803
|
-
organization_id:
|
|
3545
|
+
id: import_data5.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
|
|
3546
|
+
organization_id: import_data5.Field.lookup("sys_organization", {
|
|
2804
3547
|
label: "Organization",
|
|
2805
3548
|
required: false,
|
|
2806
3549
|
group: "System"
|
|
2807
3550
|
}),
|
|
2808
|
-
token_hash:
|
|
3551
|
+
token_hash: import_data5.Field.text({
|
|
2809
3552
|
label: "Token Hash",
|
|
2810
3553
|
required: true,
|
|
2811
3554
|
maxLength: 100,
|
|
@@ -2813,20 +3556,20 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2813
3556
|
description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
|
|
2814
3557
|
group: "Token"
|
|
2815
3558
|
}),
|
|
2816
|
-
request_id:
|
|
3559
|
+
request_id: import_data5.Field.text({
|
|
2817
3560
|
label: "Request",
|
|
2818
3561
|
required: true,
|
|
2819
3562
|
maxLength: 100,
|
|
2820
3563
|
readonly: true,
|
|
2821
3564
|
group: "Token"
|
|
2822
3565
|
}),
|
|
2823
|
-
action:
|
|
3566
|
+
action: import_data5.Field.select(["approve", "reject"], {
|
|
2824
3567
|
label: "Action",
|
|
2825
3568
|
required: true,
|
|
2826
3569
|
readonly: true,
|
|
2827
3570
|
group: "Token"
|
|
2828
3571
|
}),
|
|
2829
|
-
approver_id:
|
|
3572
|
+
approver_id: import_data5.Field.text({
|
|
2830
3573
|
label: "Approver",
|
|
2831
3574
|
required: true,
|
|
2832
3575
|
maxLength: 200,
|
|
@@ -2834,18 +3577,18 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2834
3577
|
description: "Identity the token is bound to; the decision is audited as this approver",
|
|
2835
3578
|
group: "Token"
|
|
2836
3579
|
}),
|
|
2837
|
-
expires_at:
|
|
3580
|
+
expires_at: import_data5.Field.datetime({
|
|
2838
3581
|
label: "Expires At",
|
|
2839
3582
|
required: true,
|
|
2840
3583
|
readonly: true,
|
|
2841
3584
|
group: "Lifecycle"
|
|
2842
3585
|
}),
|
|
2843
|
-
consumed_at:
|
|
3586
|
+
consumed_at: import_data5.Field.datetime({
|
|
2844
3587
|
label: "Consumed At",
|
|
2845
3588
|
required: false,
|
|
2846
3589
|
group: "Lifecycle"
|
|
2847
3590
|
}),
|
|
2848
|
-
created_at:
|
|
3591
|
+
created_at: import_data5.Field.datetime({
|
|
2849
3592
|
label: "Created At",
|
|
2850
3593
|
required: true,
|
|
2851
3594
|
defaultValue: "NOW()",
|
|
@@ -2856,7 +3599,12 @@ var SysApprovalToken = import_data4.ObjectSchema.create({
|
|
|
2856
3599
|
indexes: [
|
|
2857
3600
|
{ fields: ["token_hash"] },
|
|
2858
3601
|
{ fields: ["request_id"] }
|
|
2859
|
-
]
|
|
3602
|
+
],
|
|
3603
|
+
enable: {
|
|
3604
|
+
// [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
|
|
3605
|
+
// consumed by the approval engine (SYSTEM_CTX), never via the data API.
|
|
3606
|
+
apiMethods: ["get", "list"]
|
|
3607
|
+
}
|
|
2860
3608
|
});
|
|
2861
3609
|
|
|
2862
3610
|
// src/action-link-pages.ts
|
|
@@ -2980,6 +3728,40 @@ function bindApprovalLockHook(engine, logger) {
|
|
|
2980
3728
|
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
2981
3729
|
logger?.info?.("[approvals] record-lock hook bound");
|
|
2982
3730
|
}
|
|
3731
|
+
var DELEGATION_OBJECT = "sys_approval_delegation";
|
|
3732
|
+
function bindDelegationWriteGuard(engine, logger) {
|
|
3733
|
+
const makeGuard = (isInsert) => async (ctx) => {
|
|
3734
|
+
const session = ctx?.session ?? {};
|
|
3735
|
+
if (session.isSystem) return;
|
|
3736
|
+
const roles = session.roles ?? [];
|
|
3737
|
+
if (Array.isArray(roles) && roles.includes("admin")) return;
|
|
3738
|
+
const userId = session.userId != null ? String(session.userId) : "";
|
|
3739
|
+
const data = ctx?.input?.data;
|
|
3740
|
+
const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
|
|
3741
|
+
const deny = () => {
|
|
3742
|
+
const err = new Error(
|
|
3743
|
+
"FORBIDDEN: you may only manage out-of-office delegations where you are the delegator" + (userId ? ` ('${userId}')` : "")
|
|
3744
|
+
);
|
|
3745
|
+
err.code = "FORBIDDEN";
|
|
3746
|
+
err.statusCode = 403;
|
|
3747
|
+
throw err;
|
|
3748
|
+
};
|
|
3749
|
+
for (const row of rows) {
|
|
3750
|
+
if (!row || typeof row !== "object" || Array.isArray(row)) continue;
|
|
3751
|
+
const has = Object.prototype.hasOwnProperty.call(row, "delegator_id");
|
|
3752
|
+
const supplied = has ? String(row.delegator_id ?? "") : "";
|
|
3753
|
+
if (isInsert && (!has || supplied === "")) {
|
|
3754
|
+
if (!userId) deny();
|
|
3755
|
+
row.delegator_id = userId;
|
|
3756
|
+
continue;
|
|
3757
|
+
}
|
|
3758
|
+
if (has && supplied !== userId) deny();
|
|
3759
|
+
}
|
|
3760
|
+
};
|
|
3761
|
+
engine.registerHook("beforeInsert", makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3762
|
+
engine.registerHook("beforeUpdate", makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
3763
|
+
logger?.info?.("[approvals] delegation write-guard bound");
|
|
3764
|
+
}
|
|
2983
3765
|
function unbindAllHooks(engine) {
|
|
2984
3766
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
2985
3767
|
}
|
|
@@ -3075,7 +3857,7 @@ var ApprovalsServicePlugin = class {
|
|
|
3075
3857
|
scope: "system",
|
|
3076
3858
|
defaultDatasource: "cloud",
|
|
3077
3859
|
namespace: "sys",
|
|
3078
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
|
|
3860
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
|
|
3079
3861
|
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
3080
3862
|
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
3081
3863
|
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
@@ -3086,7 +3868,8 @@ var ApprovalsServicePlugin = class {
|
|
|
3086
3868
|
priority: 100,
|
|
3087
3869
|
items: [
|
|
3088
3870
|
{ id: "nav_approval_requests", type: "object", label: "Requests", objectName: "sys_approval_request", icon: "inbox", requiresObject: "sys_approval_request" },
|
|
3089
|
-
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" }
|
|
3871
|
+
{ id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" },
|
|
3872
|
+
{ id: "nav_approval_delegations", type: "object", label: "Delegations (OOO)", objectName: "sys_approval_delegation", icon: "user-clock", requiresObject: "sys_approval_delegation" }
|
|
3090
3873
|
]
|
|
3091
3874
|
}
|
|
3092
3875
|
]
|
|
@@ -3132,8 +3915,9 @@ var ApprovalsServicePlugin = class {
|
|
|
3132
3915
|
try {
|
|
3133
3916
|
unbindAllHooks(engine);
|
|
3134
3917
|
bindApprovalLockHook(engine, ctx.logger);
|
|
3918
|
+
bindDelegationWriteGuard(engine, ctx.logger);
|
|
3135
3919
|
} catch (err) {
|
|
3136
|
-
ctx.logger.warn?.("[approvals] failed to bind
|
|
3920
|
+
ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
|
|
3137
3921
|
}
|
|
3138
3922
|
}
|
|
3139
3923
|
ctx.registerService("approvals", this.service);
|
|
@@ -3251,6 +4035,7 @@ var ApprovalsServicePlugin = class {
|
|
|
3251
4035
|
ApprovalsServicePlugin,
|
|
3252
4036
|
SysApprovalAction,
|
|
3253
4037
|
SysApprovalApprover,
|
|
4038
|
+
SysApprovalDelegation,
|
|
3254
4039
|
SysApprovalRequest,
|
|
3255
4040
|
registerApprovalNode
|
|
3256
4041
|
});
|