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