@objectstack/plugin-approvals 9.2.0 → 9.3.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
@@ -664,6 +664,7 @@ __export(index_exports, {
664
664
  ApprovalService: () => ApprovalService,
665
665
  ApprovalsServicePlugin: () => ApprovalsServicePlugin,
666
666
  SysApprovalAction: () => SysApprovalAction,
667
+ SysApprovalApprover: () => SysApprovalApprover,
667
668
  SysApprovalRequest: () => SysApprovalRequest,
668
669
  registerApprovalNode: () => registerApprovalNode
669
670
  });
@@ -767,7 +768,9 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
767
768
  group: "Target"
768
769
  }),
769
770
  status: import_data.Field.select(
770
- ["pending", "approved", "rejected", "recalled"],
771
+ // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
772
+ // sent back for revision (ADR-0044) — terminal for this round.
773
+ ["pending", "approved", "rejected", "recalled", "returned"],
771
774
  {
772
775
  label: "Status",
773
776
  required: true,
@@ -847,9 +850,11 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
847
850
  // guard on submit and on edit-while-locked checks.
848
851
  { fields: ["object_name", "record_id"] },
849
852
  { fields: ["status", "object_name"] },
850
- // "My approvals" inbox pending_approvers is a CSV string so this
851
- // index only helps with status pre-filtering; the engine does a
852
- // post-filter substring match per row.
853
+ // Status-windowed listings (escalation sweep, "All" tab ordering).
854
+ // "My approvals" matching no longer scans this table: the service keeps
855
+ // a normalized per-approver index in `sys_approval_approver` (#1745) and
856
+ // resolves approver filters there; `pending_approvers` stays the
857
+ // human-readable CSV source of truth only.
853
858
  { fields: ["status", "updated_at"] },
854
859
  { fields: ["submitter_id", "status"] }
855
860
  ]
@@ -925,7 +930,11 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
925
930
  group: "Target"
926
931
  }),
927
932
  action: import_data2.Field.select(
928
- ["submit", "approve", "reject", "recall", "escalate"],
933
+ // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
934
+ // remind / request_info / comment are thread interactions — they never
935
+ // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
936
+ // revision and the later resubmission.
937
+ ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit"],
929
938
  {
930
939
  label: "Action",
931
940
  required: true,
@@ -952,8 +961,63 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
952
961
  ]
953
962
  });
954
963
 
964
+ // src/sys-approval-approver.object.ts
965
+ var import_data3 = require("@objectstack/spec/data");
966
+ var SysApprovalApprover = import_data3.ObjectSchema.create({
967
+ name: "sys_approval_approver",
968
+ label: "Approval Approver",
969
+ pluralLabel: "Approval Approvers",
970
+ icon: "users",
971
+ isSystem: true,
972
+ managedBy: "system",
973
+ description: "Normalized pending-approver rows for indexed inbox queries",
974
+ displayNameField: "id",
975
+ titleFormat: "{approver} \xB7 {request_id}",
976
+ compactLayout: ["request_id", "approver", "created_at"],
977
+ fields: {
978
+ id: import_data3.Field.text({ label: "Row ID", required: true, readonly: true, group: "System" }),
979
+ organization_id: import_data3.Field.lookup("sys_organization", {
980
+ label: "Organization",
981
+ required: false,
982
+ group: "System",
983
+ description: "Tenant that owns this row (mirrors the parent request)"
984
+ }),
985
+ request_id: import_data3.Field.lookup("sys_approval_request", {
986
+ label: "Request",
987
+ required: true,
988
+ group: "Target"
989
+ }),
990
+ approver: import_data3.Field.text({
991
+ label: "Approver",
992
+ required: true,
993
+ maxLength: 255,
994
+ description: "One pending-approver identity: user id, email, or role:/team: literal",
995
+ group: "Target"
996
+ }),
997
+ created_at: import_data3.Field.datetime({
998
+ label: "Created At",
999
+ required: true,
1000
+ defaultValue: "NOW()",
1001
+ readonly: true,
1002
+ group: "System"
1003
+ })
1004
+ },
1005
+ indexes: [
1006
+ // "My pending" inbox: equality on the identity literal, scoped by tenant.
1007
+ { fields: ["approver", "organization_id"] },
1008
+ // Sync path: rewrite all rows of one request on each approver-set change.
1009
+ { fields: ["request_id"] }
1010
+ ]
1011
+ });
1012
+
955
1013
  // src/approval-service.ts
1014
+ var import_node_crypto = require("crypto");
956
1015
  var import_automation = require("@objectstack/spec/automation");
1016
+ var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
1017
+ var ESCALATION_JOB_NAME = "approvals-sla-escalation";
1018
+ var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
1019
+ var SLA_ACTOR_ID = "system:sla";
1020
+ var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
957
1021
  var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
958
1022
  function uid(prefix) {
959
1023
  const g = globalThis;
@@ -1005,9 +1069,19 @@ function rowFromRequest(row) {
1005
1069
  // The row is created at submission time; expose the stable inbox-facing name.
1006
1070
  submitted_at: row.created_at ?? void 0,
1007
1071
  process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
1008
- step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step)
1072
+ step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
1073
+ sla_due_at: slaDueAt(row.created_at, cfg),
1074
+ // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
1075
+ round: typeof cfg?.__round === "number" ? cfg.__round : void 0
1009
1076
  };
1010
1077
  }
1078
+ function slaDueAt(createdAt, cfg) {
1079
+ const hours = cfg?.escalation?.timeoutHours;
1080
+ if (typeof hours !== "number" || hours <= 0 || !createdAt) return void 0;
1081
+ const t = Date.parse(String(createdAt));
1082
+ if (Number.isNaN(t)) return void 0;
1083
+ return new Date(t + hours * 36e5).toISOString();
1084
+ }
1011
1085
  function rowFromAction(row) {
1012
1086
  return {
1013
1087
  id: String(row.id),
@@ -1020,17 +1094,51 @@ function rowFromAction(row) {
1020
1094
  created_at: row.created_at ?? void 0
1021
1095
  };
1022
1096
  }
1023
- var ApprovalService = class _ApprovalService {
1097
+ var _ApprovalService = class _ApprovalService {
1024
1098
  constructor(opts) {
1025
1099
  this.engine = opts.engine;
1026
1100
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
1027
1101
  this.logger = opts.logger;
1028
1102
  this.automation = opts.automation;
1103
+ this.messaging = opts.messaging;
1104
+ this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
1029
1105
  }
1030
1106
  /** Attach (or replace) the automation surface used to resume flow runs. */
1031
1107
  attachAutomation(automation) {
1032
1108
  this.automation = automation;
1033
1109
  }
1110
+ /** Attach (or replace) the messaging surface used for thread notifications. */
1111
+ attachMessaging(messaging) {
1112
+ this.messaging = messaging;
1113
+ }
1114
+ /** Best-effort notification fan-out — failures only log. */
1115
+ async notify(input) {
1116
+ const audience = input.audience.filter((a) => a && !a.includes(":"));
1117
+ if (!this.messaging || !audience.length) return 0;
1118
+ try {
1119
+ await this.messaging.emit({ severity: "info", ...input, audience });
1120
+ return audience.length;
1121
+ } catch (err) {
1122
+ this.logger?.warn?.("[approvals] notification failed", {
1123
+ topic: input.topic,
1124
+ error: err?.message ?? String(err)
1125
+ });
1126
+ return 0;
1127
+ }
1128
+ }
1129
+ /** Load a request row and assert it is still pending. */
1130
+ async loadPendingRow(requestId) {
1131
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
1132
+ const rows = await this.engine.find("sys_approval_request", {
1133
+ where: { id: requestId },
1134
+ limit: 1,
1135
+ context: SYSTEM_CTX
1136
+ });
1137
+ const raw = Array.isArray(rows) ? rows[0] : null;
1138
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1139
+ if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1140
+ return raw;
1141
+ }
1034
1142
  /**
1035
1143
  * Expand the approvers on an Approval node into user IDs by querying the
1036
1144
  * graph tables for `team:` / `department:` / `role:` / `manager:` approver
@@ -1225,6 +1333,16 @@ var ApprovalService = class _ApprovalService {
1225
1333
  const configSnapshot = { ...input.config };
1226
1334
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1227
1335
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1336
+ try {
1337
+ const prior = await this.engine.find("sys_approval_request", {
1338
+ where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
1339
+ limit: 500,
1340
+ context: SYSTEM_CTX
1341
+ });
1342
+ const n = Array.isArray(prior) ? prior.length : 0;
1343
+ if (n > 0) configSnapshot.__round = n + 1;
1344
+ } catch {
1345
+ }
1228
1346
  const row = {
1229
1347
  id,
1230
1348
  process_name: processName,
@@ -1244,6 +1362,7 @@ var ApprovalService = class _ApprovalService {
1244
1362
  updated_at: now
1245
1363
  };
1246
1364
  await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
1365
+ await this.syncApproverIndex(id, approvers, ctxOrg, now);
1247
1366
  await this.engine.insert("sys_approval_action", {
1248
1367
  id: uid("aact"),
1249
1368
  request_id: id,
@@ -1320,6 +1439,7 @@ var ApprovalService = class _ApprovalService {
1320
1439
  pending_approvers: stillPending.join(","),
1321
1440
  updated_at: now
1322
1441
  }, { context: SYSTEM_CTX });
1442
+ await this.syncApproverIndex(requestId, stillPending, org, now);
1323
1443
  const fresh2 = await this.getRequest(requestId, context);
1324
1444
  return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
1325
1445
  }
@@ -1332,6 +1452,7 @@ var ApprovalService = class _ApprovalService {
1332
1452
  completed_at: now,
1333
1453
  updated_at: now
1334
1454
  }, { context: SYSTEM_CTX });
1455
+ await this.syncApproverIndex(requestId, [], org, now);
1335
1456
  if (config.approvalStatusField) {
1336
1457
  await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
1337
1458
  }
@@ -1374,8 +1495,14 @@ var ApprovalService = class _ApprovalService {
1374
1495
  * Withdraw a pending request (submitter only). Finalises the row as
1375
1496
  * `recalled`, releases the record lock (keyed on pending status), mirrors
1376
1497
  * the status field when configured, and resumes the owning flow run down
1377
- * the `reject` branch with `output.decision = 'recall'` — the engine has no
1378
- * run-cancel primitive, and leaving the run suspended forever would leak it.
1498
+ * the `reject` branch with `output.decision = 'recall'` — leaving the run
1499
+ * suspended forever would leak it.
1500
+ *
1501
+ * ADR-0044: also valid on the LATEST `returned` request of its run — the
1502
+ * submitter abandons the revision window instead of resubmitting. The run
1503
+ * is then paused at the revise wait node (no reject edge), so it is
1504
+ * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
1505
+ * than resumed.
1379
1506
  */
1380
1507
  async recall(requestId, input, context) {
1381
1508
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
@@ -1387,10 +1514,14 @@ var ApprovalService = class _ApprovalService {
1387
1514
  });
1388
1515
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
1389
1516
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1390
- if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1517
+ const inReviseWindow = raw.status === "returned";
1518
+ if (raw.status !== "pending" && !inReviseWindow) {
1519
+ throw new Error(`INVALID_STATE: request is ${raw.status}`);
1520
+ }
1391
1521
  if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1392
1522
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
1393
1523
  }
1524
+ if (inReviseWindow) await this.assertLatestForRun(raw);
1394
1525
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
1395
1526
  const org = raw.organization_id ?? null;
1396
1527
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
@@ -1414,11 +1545,24 @@ var ApprovalService = class _ApprovalService {
1414
1545
  completed_at: now,
1415
1546
  updated_at: now
1416
1547
  }, { context: SYSTEM_CTX });
1548
+ await this.syncApproverIndex(requestId, [], org, now);
1417
1549
  if (config.approvalStatusField) {
1418
1550
  await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
1419
1551
  }
1420
1552
  let resumed = false;
1421
- if (runId && typeof this.automation?.resume === "function") {
1553
+ if (inReviseWindow) {
1554
+ if (runId && typeof this.automation?.cancelRun === "function") {
1555
+ try {
1556
+ await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
1557
+ } catch (err) {
1558
+ this.logger?.warn?.("[approvals] cancelRun after revise-window recall failed", {
1559
+ request: requestId,
1560
+ run: runId,
1561
+ error: err?.message ?? String(err)
1562
+ });
1563
+ }
1564
+ }
1565
+ } else if (runId && typeof this.automation?.resume === "function") {
1422
1566
  try {
1423
1567
  await this.automation.resume(runId, {
1424
1568
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
@@ -1436,6 +1580,676 @@ var ApprovalService = class _ApprovalService {
1436
1580
  const fresh = await this.getRequest(requestId, context);
1437
1581
  return { request: fresh, runId, resumed };
1438
1582
  }
1583
+ // ── Send back for revision / resubmit (ADR-0044) ─────────────
1584
+ /**
1585
+ * ADR-0044 send back for revision. Finalises the pending request as
1586
+ * `returned` (a third terminal state — approver-initiated rework, distinct
1587
+ * from submitter-initiated `recalled`) and resumes the owning flow run down
1588
+ * its `revise` edge to a wait point: the record lock (keyed on `pending`)
1589
+ * releases, the submitter reworks the data, then {@link resubmit}s.
1590
+ *
1591
+ * Requires the approval node to declare a `revise` out-edge — validated
1592
+ * BEFORE any mutation, because resuming with an unmatched `branchLabel`
1593
+ * falls back to *all* out-edges. Past the node's `maxRevisions` budget the
1594
+ * request auto-rejects instead (resumes down `reject` with
1595
+ * `output.autoRejected = true`) so instances cannot orbit forever.
1596
+ */
1597
+ async sendBack(requestId, input, context) {
1598
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1599
+ const raw = await this.loadPendingRow(requestId);
1600
+ const pending = csvSplit(raw.pending_approvers);
1601
+ if (!context.isSystem && !pending.includes(input.actorId)) {
1602
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1603
+ }
1604
+ const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
1605
+ const org = raw.organization_id ?? null;
1606
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
1607
+ const runId = raw.flow_run_id ?? null;
1608
+ await this.assertReviseEdge(raw, nodeId);
1609
+ const now = this.clock.now().toISOString();
1610
+ const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
1611
+ let priorSendBacks = 0;
1612
+ if (runId && nodeId) {
1613
+ const siblings = await this.engine.find("sys_approval_request", {
1614
+ where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
1615
+ limit: 500,
1616
+ context: SYSTEM_CTX
1617
+ });
1618
+ priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
1619
+ }
1620
+ await this.engine.insert("sys_approval_action", {
1621
+ id: uid("aact"),
1622
+ request_id: requestId,
1623
+ organization_id: org,
1624
+ step_name: nodeId,
1625
+ step_index: 0,
1626
+ action: "revise",
1627
+ actor_id: input.actorId,
1628
+ comment: input.comment ?? null,
1629
+ created_at: now
1630
+ }, { context: SYSTEM_CTX });
1631
+ if (priorSendBacks >= maxRevisions) {
1632
+ await this.engine.insert("sys_approval_action", {
1633
+ id: uid("aact"),
1634
+ request_id: requestId,
1635
+ organization_id: org,
1636
+ step_name: nodeId,
1637
+ step_index: 0,
1638
+ action: "reject",
1639
+ actor_id: input.actorId,
1640
+ comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
1641
+ created_at: now
1642
+ }, { context: SYSTEM_CTX });
1643
+ await this.engine.update("sys_approval_request", {
1644
+ id: requestId,
1645
+ status: "rejected",
1646
+ pending_approvers: null,
1647
+ completed_at: now,
1648
+ updated_at: now
1649
+ }, { context: SYSTEM_CTX });
1650
+ await this.syncApproverIndex(requestId, [], org, now);
1651
+ if (config.approvalStatusField) {
1652
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "rejected");
1653
+ }
1654
+ let resumed2 = false;
1655
+ if (runId && typeof this.automation?.resume === "function") {
1656
+ try {
1657
+ await this.automation.resume(runId, {
1658
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
1659
+ output: { decision: "reject", autoRejected: true, requestId }
1660
+ });
1661
+ resumed2 = true;
1662
+ } catch (err) {
1663
+ this.logger?.warn?.("[approvals] resume after auto-reject failed", {
1664
+ request: requestId,
1665
+ run: runId,
1666
+ error: err?.message ?? String(err)
1667
+ });
1668
+ }
1669
+ }
1670
+ if (raw.submitter_id) {
1671
+ await this.notify({
1672
+ topic: "approval.returned",
1673
+ audience: [String(raw.submitter_id)],
1674
+ actorId: input.actorId,
1675
+ source: { object: "sys_approval_request", id: requestId },
1676
+ payload: {
1677
+ title: "Approval auto-rejected",
1678
+ message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
1679
+ actionUrl: "/system/approvals"
1680
+ }
1681
+ });
1682
+ }
1683
+ const fresh2 = await this.getRequest(requestId, context);
1684
+ return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
1685
+ }
1686
+ await this.engine.update("sys_approval_request", {
1687
+ id: requestId,
1688
+ status: "returned",
1689
+ pending_approvers: null,
1690
+ completed_at: now,
1691
+ updated_at: now
1692
+ }, { context: SYSTEM_CTX });
1693
+ await this.syncApproverIndex(requestId, [], org, now);
1694
+ if (config.approvalStatusField) {
1695
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "returned");
1696
+ }
1697
+ let resumed = false;
1698
+ if (runId && typeof this.automation?.resume === "function") {
1699
+ try {
1700
+ await this.automation.resume(runId, {
1701
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
1702
+ output: { decision: "revise", requestId }
1703
+ });
1704
+ resumed = true;
1705
+ } catch (err) {
1706
+ this.logger?.warn?.("[approvals] resume after send-back failed", {
1707
+ request: requestId,
1708
+ run: runId,
1709
+ error: err?.message ?? String(err)
1710
+ });
1711
+ }
1712
+ }
1713
+ if (raw.submitter_id) {
1714
+ await this.notify({
1715
+ topic: "approval.returned",
1716
+ audience: [String(raw.submitter_id)],
1717
+ actorId: input.actorId,
1718
+ source: { object: "sys_approval_request", id: requestId },
1719
+ payload: {
1720
+ title: "Sent back for revision",
1721
+ message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
1722
+ actionUrl: "/system/approvals"
1723
+ }
1724
+ });
1725
+ }
1726
+ const fresh = await this.getRequest(requestId, context);
1727
+ return { request: fresh, runId, resumed };
1728
+ }
1729
+ /**
1730
+ * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
1731
+ * its run, submitter-only. Audits `resubmit` on the returned (round-N)
1732
+ * request and resumes the run from the revise wait node; traversal walks
1733
+ * the declared back-edge into the approval node, whose executor opens the
1734
+ * round-N+1 request — fresh approver slate, record re-locks.
1735
+ */
1736
+ async resubmit(requestId, input, context) {
1737
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1738
+ const rawRows = await this.engine.find("sys_approval_request", {
1739
+ where: { id: requestId },
1740
+ limit: 1,
1741
+ context: SYSTEM_CTX
1742
+ });
1743
+ const raw = Array.isArray(rawRows) ? rawRows[0] : null;
1744
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1745
+ if (raw.status !== "returned") {
1746
+ throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
1747
+ }
1748
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1749
+ throw new Error("FORBIDDEN: only the submitter may resubmit");
1750
+ }
1751
+ await this.assertLatestForRun(raw);
1752
+ const colliding = await this.engine.find("sys_approval_request", {
1753
+ where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
1754
+ limit: 1,
1755
+ context: SYSTEM_CTX
1756
+ });
1757
+ if (Array.isArray(colliding) && colliding[0]) {
1758
+ throw new Error(
1759
+ `DUPLICATE_REQUEST: another approval request is already pending on ${raw.object_name}/${raw.record_id} \u2014 resolve it before resubmitting`
1760
+ );
1761
+ }
1762
+ const org = raw.organization_id ?? null;
1763
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
1764
+ const runId = raw.flow_run_id ?? null;
1765
+ const now = this.clock.now().toISOString();
1766
+ await this.engine.insert("sys_approval_action", {
1767
+ id: uid("aact"),
1768
+ request_id: requestId,
1769
+ organization_id: org,
1770
+ step_name: nodeId,
1771
+ step_index: 0,
1772
+ action: "resubmit",
1773
+ actor_id: input.actorId,
1774
+ comment: input.comment ?? null,
1775
+ created_at: now
1776
+ }, { context: SYSTEM_CTX });
1777
+ let resumed = false;
1778
+ if (runId && typeof this.automation?.resume === "function") {
1779
+ try {
1780
+ await this.automation.resume(runId, {
1781
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
1782
+ output: { resubmitted: true, requestId }
1783
+ });
1784
+ resumed = true;
1785
+ } catch (err) {
1786
+ this.logger?.warn?.("[approvals] resume after resubmit failed", {
1787
+ request: requestId,
1788
+ run: runId,
1789
+ error: err?.message ?? String(err)
1790
+ });
1791
+ }
1792
+ }
1793
+ const fresh = await this.getRequest(requestId, context);
1794
+ return { request: fresh, runId, resumed };
1795
+ }
1796
+ /**
1797
+ * ADR-0044 guard: the flow's approval node must declare a `revise`
1798
+ * out-edge before send-back is allowed — the engine's branch-label fallback
1799
+ * (no matching label ⇒ ALL out-edges) must never be reachable from a user
1800
+ * action.
1801
+ */
1802
+ async assertReviseEdge(raw, nodeId) {
1803
+ const processName = String(raw.process_name ?? "");
1804
+ const flowName = processName.startsWith("flow:") ? processName.slice("flow:".length) : void 0;
1805
+ if (!flowName || !nodeId || typeof this.automation?.getFlow !== "function") {
1806
+ throw new Error("VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)");
1807
+ }
1808
+ const flow = await this.automation.getFlow(flowName);
1809
+ const hasRevise = Array.isArray(flow?.edges) && flow.edges.some((e) => e?.source === nodeId && e?.label === import_automation.APPROVAL_BRANCH_LABELS.revise);
1810
+ if (!hasRevise) {
1811
+ throw new Error(
1812
+ `VALIDATION_FAILED: approval node '${nodeId}' has no '${import_automation.APPROVAL_BRANCH_LABELS.revise}' out-edge \u2014 the flow does not support send-back for revision`
1813
+ );
1814
+ }
1815
+ }
1816
+ /**
1817
+ * ADR-0044 guard: a `returned` request is only actionable (resubmit /
1818
+ * recall) while it is still the newest request on its run — a later round
1819
+ * or a later node's request supersedes it.
1820
+ */
1821
+ async assertLatestForRun(raw) {
1822
+ const runId = raw.flow_run_id;
1823
+ if (!runId) return;
1824
+ const rows = await this.engine.find("sys_approval_request", {
1825
+ where: { flow_run_id: runId },
1826
+ orderBy: [{ field: "created_at", order: "desc" }],
1827
+ limit: 1,
1828
+ context: SYSTEM_CTX
1829
+ });
1830
+ const latest = Array.isArray(rows) ? rows[0] : null;
1831
+ if (latest && String(latest.id) !== String(raw.id)) {
1832
+ throw new Error("INVALID_STATE: a newer approval request supersedes this one");
1833
+ }
1834
+ }
1835
+ // ── Thread interactions (no flow movement) ───────────────────
1836
+ /**
1837
+ * Hand a pending-approver slot to someone else. `from` defaults to the
1838
+ * actor itself; the actor must hold the slot being handed over (or be a
1839
+ * system caller). Audits `reassign` and notifies the new approver.
1840
+ */
1841
+ async reassign(requestId, input, context) {
1842
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1843
+ const to = String(input?.to ?? "").trim();
1844
+ if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
1845
+ const raw = await this.loadPendingRow(requestId);
1846
+ const pending = csvSplit(raw.pending_approvers);
1847
+ const from = String(input.from ?? input.actorId).trim();
1848
+ if (!pending.includes(from)) {
1849
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
1850
+ }
1851
+ if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
1852
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1853
+ }
1854
+ if (pending.includes(to)) {
1855
+ throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
1856
+ }
1857
+ const next = pending.map((a) => a === from ? to : a);
1858
+ const now = this.clock.now().toISOString();
1859
+ await this.engine.insert("sys_approval_action", {
1860
+ id: uid("aact"),
1861
+ request_id: requestId,
1862
+ organization_id: raw.organization_id ?? null,
1863
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
1864
+ step_index: 0,
1865
+ action: "reassign",
1866
+ actor_id: input.actorId,
1867
+ comment: input.comment ?? `${from} \u2192 ${to}`,
1868
+ created_at: now
1869
+ }, { context: SYSTEM_CTX });
1870
+ await this.engine.update("sys_approval_request", {
1871
+ id: requestId,
1872
+ pending_approvers: next.join(","),
1873
+ updated_at: now
1874
+ }, { context: SYSTEM_CTX });
1875
+ await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
1876
+ await this.notify({
1877
+ topic: "approval.reassigned",
1878
+ audience: [to],
1879
+ actorId: input.actorId,
1880
+ source: { object: "sys_approval_request", id: requestId },
1881
+ dedupKey: `approval-reassign-${requestId}-${to}`,
1882
+ payload: {
1883
+ title: "Approval handed to you",
1884
+ message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
1885
+ actionUrl: "/system/approvals"
1886
+ }
1887
+ });
1888
+ const fresh = await this.getRequest(requestId, context);
1889
+ return { request: fresh };
1890
+ }
1891
+ /**
1892
+ * Submitter nudge — notify every pending approver. Throttled to one
1893
+ * reminder per {@link REMIND_COOLDOWN_MS} per request.
1894
+ */
1895
+ async remind(requestId, input, context) {
1896
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1897
+ const raw = await this.loadPendingRow(requestId);
1898
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1899
+ throw new Error("FORBIDDEN: only the submitter may send reminders");
1900
+ }
1901
+ const acts = await this.engine.find("sys_approval_action", {
1902
+ where: { request_id: requestId, action: "remind" },
1903
+ orderBy: [{ field: "created_at", order: "desc" }],
1904
+ limit: 1,
1905
+ context: SYSTEM_CTX
1906
+ });
1907
+ const last = Array.isArray(acts) ? acts[0] : null;
1908
+ const now = this.clock.now();
1909
+ if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) {
1910
+ throw new Error("THROTTLED: a reminder was already sent recently");
1911
+ }
1912
+ const pending = csvSplit(raw.pending_approvers);
1913
+ const nowIso = now.toISOString();
1914
+ await this.engine.insert("sys_approval_action", {
1915
+ id: uid("aact"),
1916
+ request_id: requestId,
1917
+ organization_id: raw.organization_id ?? null,
1918
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
1919
+ step_index: 0,
1920
+ action: "remind",
1921
+ actor_id: input.actorId,
1922
+ comment: input.comment ?? null,
1923
+ created_at: nowIso
1924
+ }, { context: SYSTEM_CTX });
1925
+ let notified = 0;
1926
+ const concrete = pending.filter((a) => a && !a.includes(":"));
1927
+ const literals = pending.filter((a) => a && a.includes(":"));
1928
+ for (const approver of concrete) {
1929
+ try {
1930
+ const tokens = await this.issueActionTokens(requestId, approver);
1931
+ notified += await this.notify({
1932
+ topic: "approval.reminder",
1933
+ audience: [approver],
1934
+ actorId: input.actorId,
1935
+ source: { object: "sys_approval_request", id: requestId },
1936
+ dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
1937
+ payload: {
1938
+ title: "Approval reminder",
1939
+ message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1940
+ actionUrl: "/system/approvals",
1941
+ actions: [
1942
+ { label: "Approve", url: this.actionLinkUrl(tokens.approve) },
1943
+ { label: "Reject", url: this.actionLinkUrl(tokens.reject) }
1944
+ ]
1945
+ }
1946
+ });
1947
+ } catch (err) {
1948
+ this.logger?.warn?.("[approvals] reminder with action links failed", {
1949
+ request: requestId,
1950
+ approver,
1951
+ error: err?.message ?? String(err)
1952
+ });
1953
+ }
1954
+ }
1955
+ if (literals.length) {
1956
+ notified += await this.notify({
1957
+ topic: "approval.reminder",
1958
+ audience: literals,
1959
+ actorId: input.actorId,
1960
+ source: { object: "sys_approval_request", id: requestId },
1961
+ dedupKey: `approval-remind-${requestId}-${nowIso}`,
1962
+ payload: {
1963
+ title: "Approval reminder",
1964
+ message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1965
+ actionUrl: "/system/approvals"
1966
+ }
1967
+ });
1968
+ }
1969
+ const fresh = await this.getRequest(requestId, context);
1970
+ return { request: fresh, notified };
1971
+ }
1972
+ // ── Actionable links (ADR-0043) ──────────────────────────────
1973
+ /** Build the session-less confirm-page URL for a raw token. */
1974
+ actionLinkUrl(rawToken) {
1975
+ return `${this.publicBaseUrl}/api/v1/approvals/act?token=${encodeURIComponent(rawToken)}`;
1976
+ }
1977
+ /**
1978
+ * Issue one-tap approve/reject tokens for one approver on one pending
1979
+ * request. Raw tokens are returned ONCE; only SHA-256 hashes are stored
1980
+ * (`sys_approval_token`), so a DB leak yields no usable links.
1981
+ */
1982
+ async issueActionTokens(requestId, approverId, opts) {
1983
+ if (!approverId?.trim()) throw new Error("VALIDATION_FAILED: approverId is required");
1984
+ const raw = await this.loadPendingRow(requestId);
1985
+ const pending = csvSplit(raw.pending_approvers);
1986
+ if (!pending.includes(approverId)) {
1987
+ throw new Error(`FORBIDDEN: '${approverId}' is not a pending approver on this request`);
1988
+ }
1989
+ const now = this.clock.now();
1990
+ const expires = new Date(now.getTime() + (opts?.ttlMs ?? ACTION_TOKEN_TTL_MS)).toISOString();
1991
+ const out = { approve: "", reject: "" };
1992
+ for (const action of ["approve", "reject"]) {
1993
+ const rawToken = (0, import_node_crypto.randomBytes)(32).toString("base64url");
1994
+ await this.engine.insert("sys_approval_token", {
1995
+ id: uid("atok"),
1996
+ organization_id: raw.organization_id ?? null,
1997
+ token_hash: (0, import_node_crypto.createHash)("sha256").update(rawToken).digest("hex"),
1998
+ request_id: requestId,
1999
+ action,
2000
+ approver_id: approverId,
2001
+ expires_at: expires,
2002
+ consumed_at: null,
2003
+ created_at: now.toISOString()
2004
+ }, { context: SYSTEM_CTX });
2005
+ out[action] = rawToken;
2006
+ }
2007
+ return out;
2008
+ }
2009
+ /** Shared validation chain for peek/redeem. Returns the token row when live. */
2010
+ async resolveActionToken(rawToken) {
2011
+ const trimmed = rawToken?.trim();
2012
+ if (!trimmed) return { ok: false, reason: "invalid" };
2013
+ const hash = (0, import_node_crypto.createHash)("sha256").update(trimmed).digest("hex");
2014
+ const rows = await this.engine.find("sys_approval_token", {
2015
+ where: { token_hash: hash },
2016
+ limit: 1,
2017
+ context: SYSTEM_CTX
2018
+ });
2019
+ const token = Array.isArray(rows) ? rows[0] : null;
2020
+ if (!token) return { ok: false, reason: "invalid" };
2021
+ if (token.consumed_at) return { ok: false, reason: "consumed" };
2022
+ if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2023
+ return { ok: false, reason: "expired" };
2024
+ }
2025
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX);
2026
+ if (!request || request.status !== "pending") {
2027
+ return { ok: false, reason: "not_pending", request: request ?? void 0 };
2028
+ }
2029
+ if (!(request.pending_approvers ?? []).includes(token.approver_id)) {
2030
+ return { ok: false, reason: "not_approver", request };
2031
+ }
2032
+ return { ok: true, token, request };
2033
+ }
2034
+ /** GET confirm page: validate WITHOUT consuming — never mutates. */
2035
+ async peekActionToken(rawToken) {
2036
+ const res = await this.resolveActionToken(rawToken);
2037
+ if (!res.ok) return res;
2038
+ return { ok: true, action: res.token.action, request: res.request, approverId: res.token.approver_id };
2039
+ }
2040
+ /**
2041
+ * POST redemption: consume the token FIRST (a failed decide still burns
2042
+ * it — replay-safe), then decide as the bound approver.
2043
+ */
2044
+ async redeemActionToken(rawToken) {
2045
+ const res = await this.resolveActionToken(rawToken);
2046
+ if (!res.ok) return res;
2047
+ await this.engine.update("sys_approval_token", {
2048
+ id: res.token.id,
2049
+ consumed_at: this.clock.now().toISOString()
2050
+ }, { context: SYSTEM_CTX });
2051
+ const out = await this.decide(res.token.request_id, {
2052
+ decision: res.token.action,
2053
+ actorId: res.token.approver_id,
2054
+ comment: "Via action link"
2055
+ }, SYSTEM_CTX);
2056
+ return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2057
+ }
2058
+ /**
2059
+ * Approver asks the submitter for more information. The request stays
2060
+ * pending — a thread interaction, not a flow decision.
2061
+ */
2062
+ async requestInfo(requestId, input, context) {
2063
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2064
+ if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2065
+ const raw = await this.loadPendingRow(requestId);
2066
+ const pending = csvSplit(raw.pending_approvers);
2067
+ if (!context.isSystem && !pending.includes(input.actorId)) {
2068
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
2069
+ }
2070
+ const now = this.clock.now().toISOString();
2071
+ await this.engine.insert("sys_approval_action", {
2072
+ id: uid("aact"),
2073
+ request_id: requestId,
2074
+ organization_id: raw.organization_id ?? null,
2075
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
2076
+ step_index: 0,
2077
+ action: "request_info",
2078
+ actor_id: input.actorId,
2079
+ comment: input.comment.trim(),
2080
+ created_at: now
2081
+ }, { context: SYSTEM_CTX });
2082
+ if (raw.submitter_id) {
2083
+ await this.notify({
2084
+ topic: "approval.request_info",
2085
+ audience: [String(raw.submitter_id)],
2086
+ actorId: input.actorId,
2087
+ source: { object: "sys_approval_request", id: requestId },
2088
+ payload: {
2089
+ title: "More information requested",
2090
+ message: input.comment.trim(),
2091
+ actionUrl: "/system/approvals"
2092
+ }
2093
+ });
2094
+ }
2095
+ const fresh = await this.getRequest(requestId, context);
2096
+ return { request: fresh };
2097
+ }
2098
+ /** Free-form reply on the thread (submitter or any pending approver). */
2099
+ async comment(requestId, input, context) {
2100
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2101
+ if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2102
+ const raw = await this.loadPendingRow(requestId);
2103
+ const pending = csvSplit(raw.pending_approvers);
2104
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
2105
+ if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
2106
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
2107
+ }
2108
+ const now = this.clock.now().toISOString();
2109
+ await this.engine.insert("sys_approval_action", {
2110
+ id: uid("aact"),
2111
+ request_id: requestId,
2112
+ organization_id: raw.organization_id ?? null,
2113
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
2114
+ step_index: 0,
2115
+ action: "comment",
2116
+ actor_id: input.actorId,
2117
+ comment: input.comment.trim(),
2118
+ created_at: now
2119
+ }, { context: SYSTEM_CTX });
2120
+ const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
2121
+ await this.notify({
2122
+ topic: "approval.comment",
2123
+ audience,
2124
+ actorId: input.actorId,
2125
+ source: { object: "sys_approval_request", id: requestId },
2126
+ payload: {
2127
+ title: "New comment on an approval",
2128
+ message: input.comment.trim(),
2129
+ actionUrl: "/system/approvals"
2130
+ }
2131
+ });
2132
+ const fresh = await this.getRequest(requestId, context);
2133
+ return { request: fresh };
2134
+ }
2135
+ // ── SLA escalation (ADR-0042) ─────────────────────────────────
2136
+ /**
2137
+ * One escalation sweep: every *pending* request whose node config declares
2138
+ * `escalation.timeoutHours` and whose deadline has passed is escalated
2139
+ * **at most once, ever** — the `escalate` audit row is the idempotency
2140
+ * marker, written before any mutation (audit-first, like reassign). One
2141
+ * bad row never stops the sweep.
2142
+ */
2143
+ async runEscalations() {
2144
+ let rows = [];
2145
+ try {
2146
+ rows = await this.engine.find("sys_approval_request", {
2147
+ where: { status: "pending" },
2148
+ limit: 500,
2149
+ context: SYSTEM_CTX
2150
+ }) ?? [];
2151
+ } catch (err) {
2152
+ this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
2153
+ error: err?.message ?? String(err)
2154
+ });
2155
+ return { scanned: 0, escalated: 0 };
2156
+ }
2157
+ let escalated = 0;
2158
+ for (const raw of rows) {
2159
+ try {
2160
+ const cfg = parseJson(raw.node_config_json, void 0);
2161
+ const esc2 = cfg?.escalation;
2162
+ if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
2163
+ const due = slaDueAt(raw.created_at, cfg);
2164
+ if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
2165
+ const prior = await this.engine.find("sys_approval_action", {
2166
+ where: { request_id: raw.id, action: "escalate" },
2167
+ limit: 1,
2168
+ context: SYSTEM_CTX
2169
+ });
2170
+ if (Array.isArray(prior) && prior[0]) continue;
2171
+ await this.escalateRequest(raw, esc2);
2172
+ escalated++;
2173
+ } catch (err) {
2174
+ this.logger?.warn?.("[approvals] escalation failed for request", {
2175
+ request: raw?.id,
2176
+ error: err?.message ?? String(err)
2177
+ });
2178
+ }
2179
+ }
2180
+ if (escalated > 0) {
2181
+ this.logger?.info?.("[approvals] SLA escalation sweep", { scanned: rows.length, escalated });
2182
+ }
2183
+ return { scanned: rows.length, escalated };
2184
+ }
2185
+ /** Execute the configured escalation action for one overdue request. */
2186
+ async escalateRequest(raw, esc2) {
2187
+ const action = esc2.action ?? "notify";
2188
+ const escalateTo = typeof esc2.escalateTo === "string" && esc2.escalateTo.trim() ? esc2.escalateTo.trim() : void 0;
2189
+ const now = this.clock.now().toISOString();
2190
+ const pending = csvSplit(raw.pending_approvers);
2191
+ await this.engine.insert("sys_approval_action", {
2192
+ id: uid("aact"),
2193
+ request_id: raw.id,
2194
+ organization_id: raw.organization_id ?? null,
2195
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
2196
+ step_index: 0,
2197
+ action: "escalate",
2198
+ actor_id: SLA_ACTOR_ID,
2199
+ comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
2200
+ created_at: now
2201
+ }, { context: SYSTEM_CTX });
2202
+ if (action === "reassign" && escalateTo) {
2203
+ await this.engine.update("sys_approval_request", {
2204
+ id: raw.id,
2205
+ pending_approvers: escalateTo,
2206
+ updated_at: now
2207
+ }, { context: SYSTEM_CTX });
2208
+ await this.syncApproverIndex(raw.id, [escalateTo], raw.organization_id ?? null, now);
2209
+ await this.notify({
2210
+ topic: "approval.escalated",
2211
+ audience: [escalateTo],
2212
+ actorId: SLA_ACTOR_ID,
2213
+ source: { object: "sys_approval_request", id: raw.id },
2214
+ payload: {
2215
+ title: "Approval escalated to you",
2216
+ message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
2217
+ actionUrl: "/system/approvals"
2218
+ }
2219
+ });
2220
+ } else if (action === "auto_approve" || action === "auto_reject") {
2221
+ await this.decide(raw.id, {
2222
+ decision: action === "auto_approve" ? "approve" : "reject",
2223
+ actorId: SLA_ACTOR_ID,
2224
+ comment: "SLA escalation"
2225
+ }, SYSTEM_CTX);
2226
+ } else {
2227
+ await this.notify({
2228
+ topic: "approval.sla_breached",
2229
+ audience: [...pending, ...escalateTo ? [escalateTo] : []],
2230
+ actorId: SLA_ACTOR_ID,
2231
+ source: { object: "sys_approval_request", id: raw.id },
2232
+ payload: {
2233
+ title: "Approval SLA breached",
2234
+ message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
2235
+ actionUrl: "/system/approvals"
2236
+ }
2237
+ });
2238
+ }
2239
+ if (esc2.notifySubmitter !== false && raw.submitter_id) {
2240
+ await this.notify({
2241
+ topic: "approval.sla_breached",
2242
+ audience: [String(raw.submitter_id)],
2243
+ actorId: SLA_ACTOR_ID,
2244
+ source: { object: "sys_approval_request", id: raw.id },
2245
+ payload: {
2246
+ title: "Your approval request breached its SLA",
2247
+ message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
2248
+ actionUrl: "/system/approvals"
2249
+ }
2250
+ });
2251
+ }
2252
+ }
1439
2253
  // ── Display enrichment ───────────────────────────────────────
1440
2254
  /**
1441
2255
  * Resolve the schema-declared display field for an object, when the engine
@@ -1633,37 +2447,208 @@ var ApprovalService = class _ApprovalService {
1633
2447
  }
1634
2448
  }
1635
2449
  }
2450
+ // ── Pending-approver index (issue #1745) ─────────────────────
2451
+ /**
2452
+ * Mirror one request's `pending_approvers` CSV into the normalized
2453
+ * `sys_approval_approver` index. Called by every write path that changes
2454
+ * the approver set; an empty `approvers` clears the request's rows (the
2455
+ * request left `pending`). Diff-based so reassign/unanimous churn doesn't
2456
+ * rewrite untouched rows.
2457
+ */
2458
+ async syncApproverIndex(requestId, approvers, org, now) {
2459
+ const desired = new Set(approvers.map((a) => String(a).trim()).filter(Boolean));
2460
+ const existing = await this.engine.find("sys_approval_approver", {
2461
+ where: { request_id: requestId },
2462
+ limit: 500,
2463
+ context: SYSTEM_CTX
2464
+ });
2465
+ const rows = Array.isArray(existing) ? existing : [];
2466
+ for (const row of rows) {
2467
+ if (desired.has(String(row.approver))) desired.delete(String(row.approver));
2468
+ else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
2469
+ }
2470
+ for (const approver of desired) {
2471
+ await this.engine.insert("sys_approval_approver", {
2472
+ id: uid("aapr"),
2473
+ request_id: requestId,
2474
+ approver,
2475
+ organization_id: org,
2476
+ created_at: now
2477
+ }, { context: SYSTEM_CTX });
2478
+ }
2479
+ }
2480
+ /**
2481
+ * Rebuild the whole `sys_approval_approver` index from the CSV source of
2482
+ * truth. Idempotent; run at plugin start so rows written before the index
2483
+ * existed (or drifted past a crashed sync) become queryable. Cost tracks
2484
+ * the number of *pending* requests, not the request history.
2485
+ */
2486
+ async rebuildApproverIndex() {
2487
+ const desired = /* @__PURE__ */ new Map();
2488
+ const PAGE = 500;
2489
+ for (let offset = 0; ; offset += PAGE) {
2490
+ const batch = await this.engine.find("sys_approval_request", {
2491
+ where: { status: "pending" },
2492
+ fields: ["id", "pending_approvers", "organization_id"],
2493
+ limit: PAGE,
2494
+ offset,
2495
+ context: SYSTEM_CTX
2496
+ });
2497
+ const rows = Array.isArray(batch) ? batch : [];
2498
+ for (const r of rows) {
2499
+ desired.set(String(r.id), {
2500
+ approvers: new Set(csvSplit(r.pending_approvers)),
2501
+ org: r.organization_id ?? null
2502
+ });
2503
+ }
2504
+ if (rows.length < PAGE) break;
2505
+ }
2506
+ const indexRows = [];
2507
+ for (let offset = 0; ; offset += PAGE) {
2508
+ const batch = await this.engine.find("sys_approval_approver", {
2509
+ orderBy: [{ field: "created_at", order: "asc" }],
2510
+ limit: PAGE,
2511
+ offset,
2512
+ context: SYSTEM_CTX
2513
+ });
2514
+ const rows = Array.isArray(batch) ? batch : [];
2515
+ indexRows.push(...rows);
2516
+ if (rows.length < PAGE) break;
2517
+ }
2518
+ let inserted = 0;
2519
+ let deleted = 0;
2520
+ const seen = /* @__PURE__ */ new Map();
2521
+ for (const row of indexRows) {
2522
+ const reqId = String(row.request_id);
2523
+ const want = desired.get(reqId);
2524
+ const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
2525
+ if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
2526
+ await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
2527
+ deleted++;
2528
+ continue;
2529
+ }
2530
+ have.add(String(row.approver));
2531
+ }
2532
+ const now = this.clock.now().toISOString();
2533
+ for (const [reqId, want] of desired) {
2534
+ const have = seen.get(reqId);
2535
+ for (const approver of want.approvers) {
2536
+ if (have?.has(approver)) continue;
2537
+ await this.engine.insert("sys_approval_approver", {
2538
+ id: uid("aapr"),
2539
+ request_id: reqId,
2540
+ approver,
2541
+ organization_id: want.org,
2542
+ created_at: now
2543
+ }, { context: SYSTEM_CTX });
2544
+ inserted++;
2545
+ }
2546
+ }
2547
+ return { requests: desired.size, inserted, deleted };
2548
+ }
1636
2549
  // ── Read API ─────────────────────────────────────────────────
1637
- async listRequests(filter, context) {
2550
+ /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
2551
+ buildRequestWhere(filter, context) {
1638
2552
  const f = {};
1639
2553
  if (filter?.object) f.object_name = filter.object;
1640
2554
  if (filter?.recordId) f.record_id = filter.recordId;
1641
2555
  if (filter?.submitterId) f.submitter_id = filter.submitterId;
1642
- const tenantOrg = context?.organizationId ?? context?.tenantId;
2556
+ const tenantOrg = context?.organizationId ?? context?.tenantId ?? null;
1643
2557
  if (tenantOrg) f.organization_id = tenantOrg;
1644
- let statusFilter;
1645
- if (Array.isArray(filter?.status)) statusFilter = filter.status;
1646
- else if (filter?.status) f.status = filter.status;
1647
- const rows = await this.engine.find("sys_approval_request", {
1648
- where: f,
1649
- limit: 500,
1650
- orderBy: [{ field: "updated_at", direction: "desc" }],
2558
+ const q = filter?.q?.trim();
2559
+ if (q) {
2560
+ f.$or = [
2561
+ { process_name: { $contains: q } },
2562
+ { object_name: { $contains: q } },
2563
+ { record_id: { $contains: q } },
2564
+ { submitter_id: { $contains: q } },
2565
+ { payload_json: { $contains: q } }
2566
+ ];
2567
+ }
2568
+ if (Array.isArray(filter?.status)) {
2569
+ const statuses = filter.status.filter(Boolean);
2570
+ if (statuses.length === 1) f.status = statuses[0];
2571
+ else if (statuses.length > 1) f.status = { $in: statuses };
2572
+ } else if (filter?.status) {
2573
+ f.status = filter.status;
2574
+ }
2575
+ return { where: f, tenantOrg };
2576
+ }
2577
+ /**
2578
+ * Resolve an approver filter to matching request ids via the normalized
2579
+ * `sys_approval_approver` index — the indexed replacement for the old
2580
+ * in-memory CSV scan, and what makes approver-filtered pagination correct
2581
+ * past any scan window (issue #1745). A request matches when ANY of the
2582
+ * caller's identities (user id / email / role:<r>) holds a pending slot.
2583
+ * Returns null when the filter is absent (callers skip the id constraint).
2584
+ */
2585
+ async approverRequestIds(targets, tenantOrg) {
2586
+ if (!targets.length) return null;
2587
+ const where = targets.length === 1 ? { approver: targets[0] } : { approver: { $in: targets } };
2588
+ if (tenantOrg) where.organization_id = tenantOrg;
2589
+ const rows = await this.engine.find("sys_approval_approver", {
2590
+ where,
2591
+ fields: ["request_id"],
2592
+ limit: _ApprovalService.APPROVER_INDEX_CAP,
1651
2593
  context: SYSTEM_CTX
1652
2594
  });
1653
- let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
1654
- if (statusFilter) list = list.filter((r) => statusFilter.includes(r.status));
1655
- if (filter?.approverId) {
1656
- const targets = (Array.isArray(filter.approverId) ? filter.approverId : [filter.approverId]).map((t) => String(t).trim()).filter(Boolean);
1657
- if (targets.length) {
1658
- list = list.filter((r) => {
1659
- const pending = r.pending_approvers ?? [];
1660
- return targets.some((t) => pending.includes(t));
1661
- });
1662
- }
2595
+ const list = Array.isArray(rows) ? rows : [];
2596
+ if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
2597
+ this.logger?.warn?.("[approvals] approver index probe hit its window \u2014 results may be truncated", {
2598
+ cap: _ApprovalService.APPROVER_INDEX_CAP,
2599
+ targets: targets.length
2600
+ });
1663
2601
  }
2602
+ return [...new Set(list.map((r) => String(r.request_id)))];
2603
+ }
2604
+ async listRequests(filter, context) {
2605
+ const { where, tenantOrg } = this.buildRequestWhere(filter, context);
2606
+ const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
2607
+ const ids = await this.approverRequestIds(approverTargets, tenantOrg);
2608
+ if (ids) {
2609
+ if (ids.length === 0) return [];
2610
+ where.id = ids.length === 1 ? ids[0] : { $in: ids };
2611
+ }
2612
+ const findOpts = {
2613
+ where,
2614
+ orderBy: [{ field: "created_at", order: "desc" }],
2615
+ context: SYSTEM_CTX
2616
+ };
2617
+ if (filter?.limit != null || filter?.offset != null) {
2618
+ findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
2619
+ if (filter?.offset) findOpts.offset = Math.max(filter.offset, 0);
2620
+ } else {
2621
+ findOpts.limit = 500;
2622
+ }
2623
+ const rows = await this.engine.find("sys_approval_request", findOpts);
2624
+ const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
1664
2625
  await this.enrichRows(list);
1665
2626
  return list;
1666
2627
  }
2628
+ async countRequests(filter, context) {
2629
+ const { where, tenantOrg } = this.buildRequestWhere(filter, context);
2630
+ const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
2631
+ const ids = await this.approverRequestIds(approverTargets, tenantOrg);
2632
+ if (ids) {
2633
+ if (ids.length === 0) return 0;
2634
+ where.id = ids.length === 1 ? ids[0] : { $in: ids };
2635
+ }
2636
+ const countFn = this.engine.count;
2637
+ if (typeof countFn === "function") {
2638
+ try {
2639
+ const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX });
2640
+ if (typeof n === "number") return n;
2641
+ } catch {
2642
+ }
2643
+ }
2644
+ const rows = await this.engine.find("sys_approval_request", {
2645
+ where,
2646
+ fields: ["id"],
2647
+ limit: ids ? Math.max(500, ids.length) : 500,
2648
+ context: SYSTEM_CTX
2649
+ });
2650
+ return Array.isArray(rows) ? rows.length : 0;
2651
+ }
1667
2652
  async getRequest(requestId, context) {
1668
2653
  if (!requestId) return null;
1669
2654
  const where = { id: requestId };
@@ -1677,8 +2662,44 @@ var ApprovalService = class _ApprovalService {
1677
2662
  if (!Array.isArray(rows) || !rows[0]) return null;
1678
2663
  const row = rowFromRequest(rows[0]);
1679
2664
  await this.enrichRows([row]);
2665
+ await this.attachFlowSteps(row);
1680
2666
  return row;
1681
2667
  }
2668
+ /**
2669
+ * Derive approval-step progress from the owning flow's graph (single-read
2670
+ * enrichment only — list reads skip it). Walks from the start node
2671
+ * preferring `approve`/`true` edges, so the result is the flow's main
2672
+ * approval trunk; conditional side-steps show as part of the potential
2673
+ * path. Display-only and best-effort.
2674
+ */
2675
+ async attachFlowSteps(row) {
2676
+ try {
2677
+ const flowName = row.process_name?.startsWith("flow:") ? row.process_name.slice(5) : void 0;
2678
+ if (!flowName || typeof this.automation?.getFlow !== "function") return;
2679
+ const flow = await this.automation.getFlow(flowName);
2680
+ if (!flow?.nodes?.length) return;
2681
+ const nodesById = new Map(flow.nodes.map((n) => [n.id, n]));
2682
+ const steps = [];
2683
+ const seen = /* @__PURE__ */ new Set();
2684
+ let cur = flow.nodes.find((n) => n.type === "start");
2685
+ while (cur && !seen.has(cur.id)) {
2686
+ seen.add(cur.id);
2687
+ if (cur.type === "approval") steps.push({ id: cur.id, label: cur.label || cur.id });
2688
+ const out = (flow.edges ?? []).filter((e) => e.source === cur.id);
2689
+ if (!out.length) break;
2690
+ const pick = out.find((e) => e.label === "approve") ?? out.find((e) => e.label === "true") ?? out[0];
2691
+ cur = nodesById.get(pick.target);
2692
+ }
2693
+ if (steps.length === 0) return;
2694
+ const currentId = row.flow_node_id ?? row.current_step;
2695
+ const currentIdx = steps.findIndex((s) => s.id === currentId);
2696
+ row.flow_steps = steps.map((s, i) => ({
2697
+ ...s,
2698
+ state: currentIdx < 0 ? "upcoming" : i < currentIdx ? "done" : i === currentIdx ? row.status === "approved" ? "done" : "current" : "upcoming"
2699
+ }));
2700
+ } catch {
2701
+ }
2702
+ }
1682
2703
  async listActions(requestId, context) {
1683
2704
  if (!requestId) return [];
1684
2705
  const req = await this.getRequest(requestId, context);
@@ -1686,7 +2707,7 @@ var ApprovalService = class _ApprovalService {
1686
2707
  const rows = await this.engine.find("sys_approval_action", {
1687
2708
  where: { request_id: requestId },
1688
2709
  limit: 500,
1689
- orderBy: [{ field: "created_at", direction: "asc" }],
2710
+ orderBy: [{ field: "created_at", order: "asc" }],
1690
2711
  context: SYSTEM_CTX
1691
2712
  });
1692
2713
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
@@ -1700,6 +2721,153 @@ var ApprovalService = class _ApprovalService {
1700
2721
  return actions;
1701
2722
  }
1702
2723
  };
2724
+ /** Window the approver-index probe — pending queues live far below this. */
2725
+ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
2726
+ var ApprovalService = _ApprovalService;
2727
+
2728
+ // src/sys-approval-token.object.ts
2729
+ var import_data4 = require("@objectstack/spec/data");
2730
+ var SysApprovalToken = import_data4.ObjectSchema.create({
2731
+ name: "sys_approval_token",
2732
+ label: "Approval Action Token",
2733
+ pluralLabel: "Approval Action Tokens",
2734
+ icon: "key",
2735
+ isSystem: true,
2736
+ managedBy: "system",
2737
+ description: "Single-use tokens behind actionable approval links",
2738
+ displayNameField: "id",
2739
+ fields: {
2740
+ id: import_data4.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
2741
+ organization_id: import_data4.Field.lookup("sys_organization", {
2742
+ label: "Organization",
2743
+ required: false,
2744
+ group: "System"
2745
+ }),
2746
+ token_hash: import_data4.Field.text({
2747
+ label: "Token Hash",
2748
+ required: true,
2749
+ maxLength: 100,
2750
+ readonly: true,
2751
+ description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
2752
+ group: "Token"
2753
+ }),
2754
+ request_id: import_data4.Field.text({
2755
+ label: "Request",
2756
+ required: true,
2757
+ maxLength: 100,
2758
+ readonly: true,
2759
+ group: "Token"
2760
+ }),
2761
+ action: import_data4.Field.select(["approve", "reject"], {
2762
+ label: "Action",
2763
+ required: true,
2764
+ readonly: true,
2765
+ group: "Token"
2766
+ }),
2767
+ approver_id: import_data4.Field.text({
2768
+ label: "Approver",
2769
+ required: true,
2770
+ maxLength: 200,
2771
+ readonly: true,
2772
+ description: "Identity the token is bound to; the decision is audited as this approver",
2773
+ group: "Token"
2774
+ }),
2775
+ expires_at: import_data4.Field.datetime({
2776
+ label: "Expires At",
2777
+ required: true,
2778
+ readonly: true,
2779
+ group: "Lifecycle"
2780
+ }),
2781
+ consumed_at: import_data4.Field.datetime({
2782
+ label: "Consumed At",
2783
+ required: false,
2784
+ group: "Lifecycle"
2785
+ }),
2786
+ created_at: import_data4.Field.datetime({
2787
+ label: "Created At",
2788
+ required: true,
2789
+ defaultValue: "NOW()",
2790
+ readonly: true,
2791
+ group: "System"
2792
+ })
2793
+ },
2794
+ indexes: [
2795
+ { fields: ["token_hash"] },
2796
+ { fields: ["request_id"] }
2797
+ ]
2798
+ });
2799
+
2800
+ // src/action-link-pages.ts
2801
+ function esc(s) {
2802
+ return String(s ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
2803
+ }
2804
+ function shell(title, body) {
2805
+ return `<!doctype html>
2806
+ <html lang="en"><head><meta charset="utf-8">
2807
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2808
+ <meta name="robots" content="noindex">
2809
+ <title>${esc(title)}</title>
2810
+ <style>
2811
+ body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
2812
+ background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
2813
+ .card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
2814
+ padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
2815
+ h1{font-size:18px;margin:0 0 4px}
2816
+ .sub{color:#64748b;font-size:13px;margin:0 0 20px}
2817
+ .row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
2818
+ .row b{font-weight:600;text-align:right}
2819
+ .k{color:#64748b}
2820
+ .actions{margin-top:22px;display:flex;gap:10px}
2821
+ button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
2822
+ .approve{background:#059669;color:#fff}
2823
+ .reject{background:#fff;color:#dc2626;border-color:#fca5a5}
2824
+ .badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
2825
+ .ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
2826
+ a{color:#2563eb;text-decoration:none}
2827
+ .foot{margin-top:18px;font-size:12px;color:#94a3b8}
2828
+ </style></head><body><div class="card">${body}</div></body></html>`;
2829
+ }
2830
+ function summaryRows(req) {
2831
+ const rows = [
2832
+ ["Process \xB7 \u6D41\u7A0B", req.process_label || req.process_name],
2833
+ ["Step \xB7 \u6B65\u9AA4", req.step_label || req.current_step || "\u2014"],
2834
+ ["Record \xB7 \u8BB0\u5F55", req.record_title || req.record_id],
2835
+ ["Object \xB7 \u5BF9\u8C61", req.object_label || req.object_name],
2836
+ ["Requester \xB7 \u7533\u8BF7\u4EBA", req.submitter_name || req.submitter_id || "\u2014"]
2837
+ ];
2838
+ return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join("");
2839
+ }
2840
+ function renderConfirmPage(input) {
2841
+ const approving = input.action === "approve";
2842
+ const verb = approving ? "Approve \xB7 \u901A\u8FC7" : "Reject \xB7 \u62D2\u7EDD";
2843
+ return shell(`${verb} \u2014 Approval`, `
2844
+ <h1>${approving ? "\u2705 Approve this request?" : "\u26D4 Reject this request?"}</h1>
2845
+ <p class="sub">${approving ? "\u786E\u8BA4\u901A\u8FC7\u8BE5\u5BA1\u6279\u8BF7\u6C42\uFF1F" : "\u786E\u8BA4\u62D2\u7EDD\u8BE5\u5BA1\u6279\u8BF7\u6C42\uFF1F"}
2846
+ Acting as \xB7 \u64CD\u4F5C\u8EAB\u4EFD\uFF1A<b>${esc(input.approverId)}</b></p>
2847
+ ${summaryRows(input.request)}
2848
+ <form method="post" action="${esc(input.actPath)}" class="actions">
2849
+ <input type="hidden" name="token" value="${esc(input.token)}">
2850
+ <button type="submit" class="${approving ? "approve" : "reject"}">${verb}</button>
2851
+ </form>
2852
+ <p class="foot">This link is single-use and expires automatically. \xB7 \u6B64\u94FE\u63A5\u4E00\u6B21\u6709\u6548\uFF0C\u8FC7\u671F\u81EA\u52A8\u5931\u6548\u3002</p>`);
2853
+ }
2854
+ var RESULT_COPY = {
2855
+ approved: { cls: "ok", title: "\u2705 Approved \xB7 \u5DF2\u901A\u8FC7", body: "The decision was recorded. \xB7 \u5BA1\u6279\u7ED3\u679C\u5DF2\u8BB0\u5F55\u3002" },
2856
+ rejected: { cls: "ok", title: "\u26D4 Rejected \xB7 \u5DF2\u62D2\u7EDD", body: "The decision was recorded. \xB7 \u5BA1\u6279\u7ED3\u679C\u5DF2\u8BB0\u5F55\u3002" },
2857
+ invalid: { cls: "err", title: "Invalid link \xB7 \u94FE\u63A5\u65E0\u6548", body: "This link is not recognized. \xB7 \u65E0\u6CD5\u8BC6\u522B\u8BE5\u94FE\u63A5\u3002" },
2858
+ expired: { cls: "warn", title: "Link expired \xB7 \u94FE\u63A5\u5DF2\u8FC7\u671F", body: "Ask the requester to send a new reminder. \xB7 \u8BF7\u8BA9\u7533\u8BF7\u4EBA\u91CD\u65B0\u53D1\u9001\u50AC\u529E\u3002" },
2859
+ consumed: { cls: "warn", title: "Already used \xB7 \u94FE\u63A5\u5DF2\u4F7F\u7528", body: "This link was already used once. \xB7 \u8BE5\u94FE\u63A5\u5DF2\u88AB\u4F7F\u7528\u8FC7\u3002" },
2860
+ not_pending: { cls: "warn", title: "Already decided \xB7 \u8BF7\u6C42\u5DF2\u5904\u7406", body: "This request is no longer pending. \xB7 \u8BE5\u8BF7\u6C42\u5DF2\u4E0D\u5728\u5F85\u5BA1\u6279\u72B6\u6001\u3002" },
2861
+ not_approver: { cls: "warn", title: "No longer your approval \xB7 \u5DF2\u4E0D\u5728\u4F60\u540D\u4E0B", body: "This approval was handed to someone else. \xB7 \u8BE5\u5BA1\u6279\u5DF2\u8F6C\u7531\u4ED6\u4EBA\u5904\u7406\u3002" }
2862
+ };
2863
+ function renderResultPage(kind, request) {
2864
+ const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
2865
+ return shell(copy.title, `
2866
+ <span class="badge ${copy.cls}">${esc(copy.title)}</span>
2867
+ ${request ? summaryRows(request) : ""}
2868
+ <p>${esc(copy.body)}</p>
2869
+ <p class="foot"><a href="/system/approvals">Open the Approvals Inbox \xB7 \u6253\u5F00\u5BA1\u6279\u4E2D\u5FC3</a></p>`);
2870
+ }
1703
2871
 
1704
2872
  // src/lifecycle-hooks.ts
1705
2873
  var APPROVALS_HOOK_PACKAGE = "plugin-approvals:lock";
@@ -1833,6 +3001,7 @@ var ApprovalsServicePlugin = class {
1833
3001
  this.version = "1.0.0";
1834
3002
  this.type = "standard";
1835
3003
  this.dependencies = ["com.objectstack.engine.objectql"];
3004
+ this.escalationJobScheduled = false;
1836
3005
  this.options = options;
1837
3006
  }
1838
3007
  async init(ctx) {
@@ -1844,7 +3013,7 @@ var ApprovalsServicePlugin = class {
1844
3013
  scope: "system",
1845
3014
  defaultDatasource: "cloud",
1846
3015
  namespace: "sys",
1847
- objects: [SysApprovalRequest, SysApprovalAction],
3016
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
1848
3017
  // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
1849
3018
  // `group_approvals` slot. This plugin owns these objects (K2.b), so it
1850
3019
  // ships their menu too; when the plugin isn't installed the slot is empty.
@@ -1894,7 +3063,8 @@ var ApprovalsServicePlugin = class {
1894
3063
  this.engine = engine;
1895
3064
  this.service = new ApprovalService({
1896
3065
  engine,
1897
- logger: ctx.logger
3066
+ logger: ctx.logger,
3067
+ publicBaseUrl: this.options.publicBaseUrl
1898
3068
  });
1899
3069
  if (!this.options.disableAutoHooks) {
1900
3070
  try {
@@ -1906,6 +3076,86 @@ var ApprovalsServicePlugin = class {
1906
3076
  }
1907
3077
  ctx.registerService("approvals", this.service);
1908
3078
  ctx.logger.info("ApprovalsServicePlugin: service registered");
3079
+ try {
3080
+ const messaging = ctx.getService("messaging");
3081
+ if (messaging && typeof messaging.emit === "function") {
3082
+ this.service.attachMessaging(messaging);
3083
+ }
3084
+ } catch {
3085
+ }
3086
+ const wireEscalationClock = async () => {
3087
+ try {
3088
+ const jobs = ctx.getService("job");
3089
+ if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
3090
+ const svc = this.service;
3091
+ const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
3092
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, async () => {
3093
+ await svc.runEscalations();
3094
+ });
3095
+ this.escalationJobScheduled = true;
3096
+ void svc.runEscalations().catch((err) => {
3097
+ ctx.logger.warn?.("[approvals] boot escalation sweep failed", { error: err?.message });
3098
+ });
3099
+ ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
3100
+ } catch {
3101
+ }
3102
+ };
3103
+ const mountActionPages = async () => {
3104
+ try {
3105
+ const http = ctx.getService("http-server");
3106
+ const rawApp = http && typeof http.getRawApp === "function" ? http.getRawApp() : null;
3107
+ if (!rawApp || !this.service) return;
3108
+ const svc = this.service;
3109
+ const ACT_PATH = "/api/v1/approvals/act";
3110
+ const html = (c, body, status = 200) => c.body(body, status, { "Content-Type": "text/html; charset=utf-8" });
3111
+ rawApp.get(ACT_PATH, async (c) => {
3112
+ const token = String(c.req.query("token") ?? "");
3113
+ const peek = await svc.peekActionToken(token);
3114
+ if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
3115
+ return html(c, renderConfirmPage({
3116
+ request: peek.request,
3117
+ action: peek.action,
3118
+ approverId: peek.approverId,
3119
+ token,
3120
+ actPath: ACT_PATH
3121
+ }));
3122
+ });
3123
+ rawApp.post(ACT_PATH, async (c) => {
3124
+ let token = "";
3125
+ try {
3126
+ const body = await c.req.parseBody();
3127
+ token = String(body?.token ?? "");
3128
+ } catch {
3129
+ }
3130
+ const out = await svc.redeemActionToken(token);
3131
+ if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
3132
+ return html(c, renderResultPage(out.action === "approve" ? "approved" : "rejected", out.request));
3133
+ });
3134
+ ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
3135
+ } catch {
3136
+ }
3137
+ };
3138
+ const backfillApproverIndex = async () => {
3139
+ try {
3140
+ const svc = this.service;
3141
+ if (!svc) return;
3142
+ const out = await svc.rebuildApproverIndex();
3143
+ if (out.inserted > 0 || out.deleted > 0) {
3144
+ ctx.logger.info("ApprovalsServicePlugin: approver index rebuilt", out);
3145
+ }
3146
+ } catch (err) {
3147
+ ctx.logger.warn?.("[approvals] approver index backfill failed", { error: err?.message });
3148
+ }
3149
+ };
3150
+ if (typeof ctx.hook === "function") {
3151
+ ctx.hook("kernel:ready", wireEscalationClock);
3152
+ ctx.hook("kernel:ready", mountActionPages);
3153
+ ctx.hook("kernel:ready", backfillApproverIndex);
3154
+ } else {
3155
+ await wireEscalationClock();
3156
+ await mountActionPages();
3157
+ await backfillApproverIndex();
3158
+ }
1909
3159
  try {
1910
3160
  const automation = ctx.getService("automation");
1911
3161
  if (automation && typeof automation.registerNodeExecutor === "function") {
@@ -1916,7 +3166,15 @@ var ApprovalsServicePlugin = class {
1916
3166
  ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
1917
3167
  }
1918
3168
  }
1919
- async stop(_ctx) {
3169
+ async stop(ctx) {
3170
+ if (this.escalationJobScheduled) {
3171
+ try {
3172
+ const jobs = ctx.getService("job");
3173
+ await jobs?.cancel?.(ESCALATION_JOB_NAME);
3174
+ } catch {
3175
+ }
3176
+ this.escalationJobScheduled = false;
3177
+ }
1920
3178
  if (this.engine) {
1921
3179
  try {
1922
3180
  unbindAllHooks(this.engine);
@@ -1930,6 +3188,7 @@ var ApprovalsServicePlugin = class {
1930
3188
  ApprovalService,
1931
3189
  ApprovalsServicePlugin,
1932
3190
  SysApprovalAction,
3191
+ SysApprovalApprover,
1933
3192
  SysApprovalRequest,
1934
3193
  registerApprovalNode
1935
3194
  });