@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.0

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