@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/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: "system",
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
- ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit"],
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: "system",
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:` / `role:` /
1156
- * `manager:` approver types. Falls back to a prefixed literal
1157
- * (`type:value`) when graph lookups produce nothing — so existing fixtures
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
- * - `role` → users with `sys_member.role = value` in tenant — the
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
- for (const a of step.approvers) {
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
- if (a.type === "user") {
1179
- out.push(String(a.value));
1180
- continue;
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
- if (a.type === "field" && record) {
1183
- out.push(String(record[a.value] ?? ""));
1184
- continue;
1185
- }
1186
- try {
1187
- if (a.type === "team") {
1188
- const users = await this.expandTeamUsers(String(a.value));
1189
- if (users.length) {
1190
- for (const u of users) out.push(u);
1191
- continue;
1192
- }
1193
- } else if (a.type === "department" || a.type === "business_unit" || a.type === "bu") {
1194
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
1195
- if (users.length) {
1196
- for (const u of users) out.push(u);
1197
- continue;
1198
- }
1199
- } else if (a.type === "position") {
1200
- const users = await this.expandPositionUsers(String(a.value), organizationId);
1201
- if (users.length) {
1202
- for (const u of users) out.push(u);
1203
- continue;
1204
- }
1205
- } else if (a.type === "role") {
1206
- const users = await this.expandRoleUsers(String(a.value), organizationId);
1207
- if (users.length) {
1208
- for (const u of users) out.push(u);
1209
- continue;
1210
- }
1211
- } else if (a.type === "manager" && record) {
1212
- const subject = record[a.value] ?? record.owner_id;
1213
- if (subject) {
1214
- const mgr = await this.lookupManager(String(subject));
1215
- if (mgr) {
1216
- out.push(mgr);
1217
- continue;
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
- out.push(`${a.type}:${a.value}`);
1737
+ } catch {
1224
1738
  }
1225
- return out.filter(Boolean);
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.expandRoleUsers(positionName, organizationId)) users.add(uid2);
1831
+ for (const uid2 of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid2);
1318
1832
  return Array.from(users);
1319
1833
  }
1320
- /** better-auth org-membership tier (`sys_member.role`) — NOT positions. */
1321
- async expandRoleUsers(roleName, organizationId) {
1322
- if (!roleName) return [];
1323
- const filter = { role: roleName };
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 approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
1381
- const now = this.clock.now().toISOString();
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
- * Record a decision on a node-driven request. Honours the node's `unanimous`
1435
- * behavior (holds until every approver has approved). When the request
1436
- * finalizes, returns the suspended run id + node id so the caller (or
1437
- * {@link ApprovalService.decide}) can resume the flow down the matching
1438
- * branch.
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
- if (input.decision === "approve" && config.behavior === "unanimous") {
1475
- const original = await this.expandApprovers(
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: 500,
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 stillPending = original.filter((a) => !approved.has(a));
1487
- if (stillPending.length > 0) {
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 import_data4 = require("@objectstack/spec/data");
2790
- var SysApprovalToken = import_data4.ObjectSchema.create({
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: "system",
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: import_data4.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
2803
- organization_id: import_data4.Field.lookup("sys_organization", {
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: import_data4.Field.text({
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: import_data4.Field.text({
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: import_data4.Field.select(["approve", "reject"], {
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: import_data4.Field.text({
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: import_data4.Field.datetime({
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: import_data4.Field.datetime({
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: import_data4.Field.datetime({
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 record-lock hook", { error: err?.message });
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
  });