@objectstack/plugin-approvals 9.1.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.mjs CHANGED
@@ -744,7 +744,9 @@ var SysApprovalRequest = ObjectSchema.create({
744
744
  group: "Target"
745
745
  }),
746
746
  status: Field.select(
747
- ["pending", "approved", "rejected", "recalled"],
747
+ // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
748
+ // sent back for revision (ADR-0044) — terminal for this round.
749
+ ["pending", "approved", "rejected", "recalled", "returned"],
748
750
  {
749
751
  label: "Status",
750
752
  required: true,
@@ -824,9 +826,11 @@ var SysApprovalRequest = ObjectSchema.create({
824
826
  // guard on submit and on edit-while-locked checks.
825
827
  { fields: ["object_name", "record_id"] },
826
828
  { fields: ["status", "object_name"] },
827
- // "My approvals" inbox pending_approvers is a CSV string so this
828
- // index only helps with status pre-filtering; the engine does a
829
- // post-filter substring match per row.
829
+ // Status-windowed listings (escalation sweep, "All" tab ordering).
830
+ // "My approvals" matching no longer scans this table: the service keeps
831
+ // a normalized per-approver index in `sys_approval_approver` (#1745) and
832
+ // resolves approver filters there; `pending_approvers` stays the
833
+ // human-readable CSV source of truth only.
830
834
  { fields: ["status", "updated_at"] },
831
835
  { fields: ["submitter_id", "status"] }
832
836
  ]
@@ -902,7 +906,11 @@ var SysApprovalAction = ObjectSchema2.create({
902
906
  group: "Target"
903
907
  }),
904
908
  action: Field2.select(
905
- ["submit", "approve", "reject", "recall", "escalate"],
909
+ // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
910
+ // remind / request_info / comment are thread interactions — they never
911
+ // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
912
+ // revision and the later resubmission.
913
+ ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit"],
906
914
  {
907
915
  label: "Action",
908
916
  required: true,
@@ -929,10 +937,65 @@ var SysApprovalAction = ObjectSchema2.create({
929
937
  ]
930
938
  });
931
939
 
940
+ // src/sys-approval-approver.object.ts
941
+ import { ObjectSchema as ObjectSchema3, Field as Field3 } from "@objectstack/spec/data";
942
+ var SysApprovalApprover = ObjectSchema3.create({
943
+ name: "sys_approval_approver",
944
+ label: "Approval Approver",
945
+ pluralLabel: "Approval Approvers",
946
+ icon: "users",
947
+ isSystem: true,
948
+ managedBy: "system",
949
+ description: "Normalized pending-approver rows for indexed inbox queries",
950
+ displayNameField: "id",
951
+ titleFormat: "{approver} \xB7 {request_id}",
952
+ compactLayout: ["request_id", "approver", "created_at"],
953
+ fields: {
954
+ id: Field3.text({ label: "Row ID", required: true, readonly: true, group: "System" }),
955
+ organization_id: Field3.lookup("sys_organization", {
956
+ label: "Organization",
957
+ required: false,
958
+ group: "System",
959
+ description: "Tenant that owns this row (mirrors the parent request)"
960
+ }),
961
+ request_id: Field3.lookup("sys_approval_request", {
962
+ label: "Request",
963
+ required: true,
964
+ group: "Target"
965
+ }),
966
+ approver: Field3.text({
967
+ label: "Approver",
968
+ required: true,
969
+ maxLength: 255,
970
+ description: "One pending-approver identity: user id, email, or role:/team: literal",
971
+ group: "Target"
972
+ }),
973
+ created_at: Field3.datetime({
974
+ label: "Created At",
975
+ required: true,
976
+ defaultValue: "NOW()",
977
+ readonly: true,
978
+ group: "System"
979
+ })
980
+ },
981
+ indexes: [
982
+ // "My pending" inbox: equality on the identity literal, scoped by tenant.
983
+ { fields: ["approver", "organization_id"] },
984
+ // Sync path: rewrite all rows of one request on each approver-set change.
985
+ { fields: ["request_id"] }
986
+ ]
987
+ });
988
+
932
989
  // src/approval-service.ts
990
+ import { createHash, randomBytes } from "crypto";
933
991
  import {
934
992
  APPROVAL_BRANCH_LABELS
935
993
  } from "@objectstack/spec/automation";
994
+ var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
995
+ var ESCALATION_JOB_NAME = "approvals-sla-escalation";
996
+ var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
997
+ var SLA_ACTOR_ID = "system:sla";
998
+ var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
936
999
  var SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
937
1000
  function uid(prefix) {
938
1001
  const g = globalThis;
@@ -984,9 +1047,19 @@ function rowFromRequest(row) {
984
1047
  // The row is created at submission time; expose the stable inbox-facing name.
985
1048
  submitted_at: row.created_at ?? void 0,
986
1049
  process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
987
- step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step)
1050
+ step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
1051
+ sla_due_at: slaDueAt(row.created_at, cfg),
1052
+ // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
1053
+ round: typeof cfg?.__round === "number" ? cfg.__round : void 0
988
1054
  };
989
1055
  }
1056
+ function slaDueAt(createdAt, cfg) {
1057
+ const hours = cfg?.escalation?.timeoutHours;
1058
+ if (typeof hours !== "number" || hours <= 0 || !createdAt) return void 0;
1059
+ const t = Date.parse(String(createdAt));
1060
+ if (Number.isNaN(t)) return void 0;
1061
+ return new Date(t + hours * 36e5).toISOString();
1062
+ }
990
1063
  function rowFromAction(row) {
991
1064
  return {
992
1065
  id: String(row.id),
@@ -999,17 +1072,51 @@ function rowFromAction(row) {
999
1072
  created_at: row.created_at ?? void 0
1000
1073
  };
1001
1074
  }
1002
- var ApprovalService = class _ApprovalService {
1075
+ var _ApprovalService = class _ApprovalService {
1003
1076
  constructor(opts) {
1004
1077
  this.engine = opts.engine;
1005
1078
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
1006
1079
  this.logger = opts.logger;
1007
1080
  this.automation = opts.automation;
1081
+ this.messaging = opts.messaging;
1082
+ this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
1008
1083
  }
1009
1084
  /** Attach (or replace) the automation surface used to resume flow runs. */
1010
1085
  attachAutomation(automation) {
1011
1086
  this.automation = automation;
1012
1087
  }
1088
+ /** Attach (or replace) the messaging surface used for thread notifications. */
1089
+ attachMessaging(messaging) {
1090
+ this.messaging = messaging;
1091
+ }
1092
+ /** Best-effort notification fan-out — failures only log. */
1093
+ async notify(input) {
1094
+ const audience = input.audience.filter((a) => a && !a.includes(":"));
1095
+ if (!this.messaging || !audience.length) return 0;
1096
+ try {
1097
+ await this.messaging.emit({ severity: "info", ...input, audience });
1098
+ return audience.length;
1099
+ } catch (err) {
1100
+ this.logger?.warn?.("[approvals] notification failed", {
1101
+ topic: input.topic,
1102
+ error: err?.message ?? String(err)
1103
+ });
1104
+ return 0;
1105
+ }
1106
+ }
1107
+ /** Load a request row and assert it is still pending. */
1108
+ async loadPendingRow(requestId) {
1109
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
1110
+ const rows = await this.engine.find("sys_approval_request", {
1111
+ where: { id: requestId },
1112
+ limit: 1,
1113
+ context: SYSTEM_CTX
1114
+ });
1115
+ const raw = Array.isArray(rows) ? rows[0] : null;
1116
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1117
+ if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1118
+ return raw;
1119
+ }
1013
1120
  /**
1014
1121
  * Expand the approvers on an Approval node into user IDs by querying the
1015
1122
  * graph tables for `team:` / `department:` / `role:` / `manager:` approver
@@ -1204,6 +1311,16 @@ var ApprovalService = class _ApprovalService {
1204
1311
  const configSnapshot = { ...input.config };
1205
1312
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1206
1313
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1314
+ try {
1315
+ const prior = await this.engine.find("sys_approval_request", {
1316
+ where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
1317
+ limit: 500,
1318
+ context: SYSTEM_CTX
1319
+ });
1320
+ const n = Array.isArray(prior) ? prior.length : 0;
1321
+ if (n > 0) configSnapshot.__round = n + 1;
1322
+ } catch {
1323
+ }
1207
1324
  const row = {
1208
1325
  id,
1209
1326
  process_name: processName,
@@ -1223,6 +1340,7 @@ var ApprovalService = class _ApprovalService {
1223
1340
  updated_at: now
1224
1341
  };
1225
1342
  await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
1343
+ await this.syncApproverIndex(id, approvers, ctxOrg, now);
1226
1344
  await this.engine.insert("sys_approval_action", {
1227
1345
  id: uid("aact"),
1228
1346
  request_id: id,
@@ -1299,6 +1417,7 @@ var ApprovalService = class _ApprovalService {
1299
1417
  pending_approvers: stillPending.join(","),
1300
1418
  updated_at: now
1301
1419
  }, { context: SYSTEM_CTX });
1420
+ await this.syncApproverIndex(requestId, stillPending, org, now);
1302
1421
  const fresh2 = await this.getRequest(requestId, context);
1303
1422
  return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
1304
1423
  }
@@ -1311,6 +1430,7 @@ var ApprovalService = class _ApprovalService {
1311
1430
  completed_at: now,
1312
1431
  updated_at: now
1313
1432
  }, { context: SYSTEM_CTX });
1433
+ await this.syncApproverIndex(requestId, [], org, now);
1314
1434
  if (config.approvalStatusField) {
1315
1435
  await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
1316
1436
  }
@@ -1353,8 +1473,14 @@ var ApprovalService = class _ApprovalService {
1353
1473
  * Withdraw a pending request (submitter only). Finalises the row as
1354
1474
  * `recalled`, releases the record lock (keyed on pending status), mirrors
1355
1475
  * the status field when configured, and resumes the owning flow run down
1356
- * the `reject` branch with `output.decision = 'recall'` — the engine has no
1357
- * run-cancel primitive, and leaving the run suspended forever would leak it.
1476
+ * the `reject` branch with `output.decision = 'recall'` — leaving the run
1477
+ * suspended forever would leak it.
1478
+ *
1479
+ * ADR-0044: also valid on the LATEST `returned` request of its run — the
1480
+ * submitter abandons the revision window instead of resubmitting. The run
1481
+ * is then paused at the revise wait node (no reject edge), so it is
1482
+ * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
1483
+ * than resumed.
1358
1484
  */
1359
1485
  async recall(requestId, input, context) {
1360
1486
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
@@ -1366,10 +1492,14 @@ var ApprovalService = class _ApprovalService {
1366
1492
  });
1367
1493
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
1368
1494
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1369
- if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1495
+ const inReviseWindow = raw.status === "returned";
1496
+ if (raw.status !== "pending" && !inReviseWindow) {
1497
+ throw new Error(`INVALID_STATE: request is ${raw.status}`);
1498
+ }
1370
1499
  if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1371
1500
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
1372
1501
  }
1502
+ if (inReviseWindow) await this.assertLatestForRun(raw);
1373
1503
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
1374
1504
  const org = raw.organization_id ?? null;
1375
1505
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
@@ -1393,11 +1523,24 @@ var ApprovalService = class _ApprovalService {
1393
1523
  completed_at: now,
1394
1524
  updated_at: now
1395
1525
  }, { context: SYSTEM_CTX });
1526
+ await this.syncApproverIndex(requestId, [], org, now);
1396
1527
  if (config.approvalStatusField) {
1397
1528
  await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
1398
1529
  }
1399
1530
  let resumed = false;
1400
- if (runId && typeof this.automation?.resume === "function") {
1531
+ if (inReviseWindow) {
1532
+ if (runId && typeof this.automation?.cancelRun === "function") {
1533
+ try {
1534
+ await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
1535
+ } catch (err) {
1536
+ this.logger?.warn?.("[approvals] cancelRun after revise-window recall failed", {
1537
+ request: requestId,
1538
+ run: runId,
1539
+ error: err?.message ?? String(err)
1540
+ });
1541
+ }
1542
+ }
1543
+ } else if (runId && typeof this.automation?.resume === "function") {
1401
1544
  try {
1402
1545
  await this.automation.resume(runId, {
1403
1546
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
@@ -1415,168 +1558,1297 @@ var ApprovalService = class _ApprovalService {
1415
1558
  const fresh = await this.getRequest(requestId, context);
1416
1559
  return { request: fresh, runId, resumed };
1417
1560
  }
1418
- // ── Display enrichment ───────────────────────────────────────
1561
+ // ── Send back for revision / resubmit (ADR-0044) ─────────────
1419
1562
  /**
1420
- * Resolve the schema-declared display field for an object, when the engine
1421
- * exposes schema metadata (`getSchema`). Falls back to common title-ish
1422
- * field names so plain `ApprovalEngine` fakes still enrich sensibly.
1563
+ * ADR-0044 send back for revision. Finalises the pending request as
1564
+ * `returned` (a third terminal state approver-initiated rework, distinct
1565
+ * from submitter-initiated `recalled`) and resumes the owning flow run down
1566
+ * its `revise` edge to a wait point: the record lock (keyed on `pending`)
1567
+ * releases, the submitter reworks the data, then {@link resubmit}s.
1568
+ *
1569
+ * Requires the approval node to declare a `revise` out-edge — validated
1570
+ * BEFORE any mutation, because resuming with an unmatched `branchLabel`
1571
+ * falls back to *all* out-edges. Past the node's `maxRevisions` budget the
1572
+ * request auto-rejects instead (resumes down `reject` with
1573
+ * `output.autoRejected = true`) so instances cannot orbit forever.
1423
1574
  */
1424
- resolveDisplayField(object) {
1425
- try {
1426
- const schema = this.engine.getSchema?.(object);
1427
- const fields = schema?.fields ?? {};
1428
- const declared = schema?.displayNameField;
1429
- if (declared && declared !== "id" && fields[declared]) return declared;
1430
- for (const cand of ["name", "title", "subject", "label"]) {
1431
- if (fields[cand]) return cand;
1432
- }
1433
- } catch {
1575
+ async sendBack(requestId, input, context) {
1576
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1577
+ const raw = await this.loadPendingRow(requestId);
1578
+ const pending = csvSplit(raw.pending_approvers);
1579
+ if (!context.isSystem && !pending.includes(input.actorId)) {
1580
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1434
1581
  }
1435
- return void 0;
1436
- }
1437
- static pickTitle(rec, displayField) {
1438
- const candidates = displayField ? [displayField, "name", "title", "subject", "label"] : ["name", "title", "subject", "label"];
1439
- for (const f of candidates) {
1440
- const v = rec?.[f];
1441
- if (v != null && String(v).trim() && f !== "id") return String(v);
1582
+ const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
1583
+ const org = raw.organization_id ?? null;
1584
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
1585
+ const runId = raw.flow_run_id ?? null;
1586
+ await this.assertReviseEdge(raw, nodeId);
1587
+ const now = this.clock.now().toISOString();
1588
+ const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
1589
+ let priorSendBacks = 0;
1590
+ if (runId && nodeId) {
1591
+ const siblings = await this.engine.find("sys_approval_request", {
1592
+ where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
1593
+ limit: 500,
1594
+ context: SYSTEM_CTX
1595
+ });
1596
+ priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
1442
1597
  }
1443
- return void 0;
1444
- }
1445
- /**
1446
- * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
1447
- * Batched: one query per distinct target object plus one `sys_user` lookup.
1448
- * Best-effort — a deleted record falls back to the payload snapshot, and a
1449
- * lookup failure leaves the field unset rather than failing the list.
1450
- */
1451
- async enrichRows(rows) {
1452
- if (!rows.length) return;
1453
- const byObject = /* @__PURE__ */ new Map();
1454
- for (const r of rows) {
1455
- if (!r.object_name || !r.record_id) continue;
1456
- let set = byObject.get(r.object_name);
1457
- if (!set) {
1458
- set = /* @__PURE__ */ new Set();
1459
- byObject.set(r.object_name, set);
1598
+ await this.engine.insert("sys_approval_action", {
1599
+ id: uid("aact"),
1600
+ request_id: requestId,
1601
+ organization_id: org,
1602
+ step_name: nodeId,
1603
+ step_index: 0,
1604
+ action: "revise",
1605
+ actor_id: input.actorId,
1606
+ comment: input.comment ?? null,
1607
+ created_at: now
1608
+ }, { context: SYSTEM_CTX });
1609
+ if (priorSendBacks >= maxRevisions) {
1610
+ await this.engine.insert("sys_approval_action", {
1611
+ id: uid("aact"),
1612
+ request_id: requestId,
1613
+ organization_id: org,
1614
+ step_name: nodeId,
1615
+ step_index: 0,
1616
+ action: "reject",
1617
+ actor_id: input.actorId,
1618
+ comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
1619
+ created_at: now
1620
+ }, { context: SYSTEM_CTX });
1621
+ await this.engine.update("sys_approval_request", {
1622
+ id: requestId,
1623
+ status: "rejected",
1624
+ pending_approvers: null,
1625
+ completed_at: now,
1626
+ updated_at: now
1627
+ }, { context: SYSTEM_CTX });
1628
+ await this.syncApproverIndex(requestId, [], org, now);
1629
+ if (config.approvalStatusField) {
1630
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "rejected");
1460
1631
  }
1461
- set.add(r.record_id);
1462
- }
1463
- const titles = /* @__PURE__ */ new Map();
1464
- for (const [object, idSet] of byObject) {
1465
- const ids = Array.from(idSet);
1466
- const displayField = this.resolveDisplayField(object);
1467
- try {
1468
- const recs = await this.engine.find(object, {
1469
- where: { id: { $in: ids } },
1470
- limit: ids.length,
1471
- context: SYSTEM_CTX
1472
- });
1473
- for (const rec of recs ?? []) {
1474
- const title = _ApprovalService.pickTitle(rec, displayField);
1475
- if (rec?.id && title) titles.set(`${object}\0${rec.id}`, title);
1632
+ let resumed2 = false;
1633
+ if (runId && typeof this.automation?.resume === "function") {
1634
+ try {
1635
+ await this.automation.resume(runId, {
1636
+ branchLabel: APPROVAL_BRANCH_LABELS.reject,
1637
+ output: { decision: "reject", autoRejected: true, requestId }
1638
+ });
1639
+ resumed2 = true;
1640
+ } catch (err) {
1641
+ this.logger?.warn?.("[approvals] resume after auto-reject failed", {
1642
+ request: requestId,
1643
+ run: runId,
1644
+ error: err?.message ?? String(err)
1645
+ });
1476
1646
  }
1477
- } catch {
1478
1647
  }
1648
+ if (raw.submitter_id) {
1649
+ await this.notify({
1650
+ topic: "approval.returned",
1651
+ audience: [String(raw.submitter_id)],
1652
+ actorId: input.actorId,
1653
+ source: { object: "sys_approval_request", id: requestId },
1654
+ payload: {
1655
+ title: "Approval auto-rejected",
1656
+ message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
1657
+ actionUrl: "/system/approvals"
1658
+ }
1659
+ });
1660
+ }
1661
+ const fresh2 = await this.getRequest(requestId, context);
1662
+ return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
1479
1663
  }
1480
- const submitters = Array.from(new Set(rows.map((r) => r.submitter_id).filter(Boolean)));
1481
- const names = /* @__PURE__ */ new Map();
1482
- if (submitters.length) {
1664
+ await this.engine.update("sys_approval_request", {
1665
+ id: requestId,
1666
+ status: "returned",
1667
+ pending_approvers: null,
1668
+ completed_at: now,
1669
+ updated_at: now
1670
+ }, { context: SYSTEM_CTX });
1671
+ await this.syncApproverIndex(requestId, [], org, now);
1672
+ if (config.approvalStatusField) {
1673
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "returned");
1674
+ }
1675
+ let resumed = false;
1676
+ if (runId && typeof this.automation?.resume === "function") {
1483
1677
  try {
1484
- const users = await this.engine.find("sys_user", {
1485
- where: { id: { $in: submitters } },
1486
- fields: ["id", "name", "email"],
1487
- limit: submitters.length,
1488
- context: SYSTEM_CTX
1678
+ await this.automation.resume(runId, {
1679
+ branchLabel: APPROVAL_BRANCH_LABELS.revise,
1680
+ output: { decision: "revise", requestId }
1681
+ });
1682
+ resumed = true;
1683
+ } catch (err) {
1684
+ this.logger?.warn?.("[approvals] resume after send-back failed", {
1685
+ request: requestId,
1686
+ run: runId,
1687
+ error: err?.message ?? String(err)
1489
1688
  });
1490
- for (const u of users ?? []) {
1491
- if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
1492
- }
1493
- } catch {
1494
- }
1495
- const unresolvedEmails = submitters.filter((s) => !names.has(s) && s.includes("@"));
1496
- if (unresolvedEmails.length) {
1497
- try {
1498
- const users = await this.engine.find("sys_user", {
1499
- where: { email: { $in: unresolvedEmails } },
1500
- fields: ["email", "name"],
1501
- limit: unresolvedEmails.length,
1502
- context: SYSTEM_CTX
1503
- });
1504
- for (const u of users ?? []) {
1505
- if (u?.email && u.name) names.set(String(u.email), String(u.name));
1506
- }
1507
- } catch {
1508
- }
1509
1689
  }
1510
1690
  }
1511
- for (const r of rows) {
1512
- const title = titles.get(`${r.object_name}\0${r.record_id}`) ?? _ApprovalService.pickTitle(r.payload, void 0);
1513
- if (title) r.record_title = title;
1514
- const name = r.submitter_id ? names.get(String(r.submitter_id)) : void 0;
1515
- if (name) r.submitter_name = name;
1691
+ if (raw.submitter_id) {
1692
+ await this.notify({
1693
+ topic: "approval.returned",
1694
+ audience: [String(raw.submitter_id)],
1695
+ actorId: input.actorId,
1696
+ source: { object: "sys_approval_request", id: requestId },
1697
+ payload: {
1698
+ title: "Sent back for revision",
1699
+ message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
1700
+ actionUrl: "/system/approvals"
1701
+ }
1702
+ });
1516
1703
  }
1704
+ const fresh = await this.getRequest(requestId, context);
1705
+ return { request: fresh, runId, resumed };
1517
1706
  }
1518
- // ── Read API ─────────────────────────────────────────────────
1519
- async listRequests(filter, context) {
1520
- const f = {};
1521
- if (filter?.object) f.object_name = filter.object;
1522
- if (filter?.recordId) f.record_id = filter.recordId;
1523
- if (filter?.submitterId) f.submitter_id = filter.submitterId;
1524
- const tenantOrg = context?.organizationId ?? context?.tenantId;
1525
- if (tenantOrg) f.organization_id = tenantOrg;
1526
- let statusFilter;
1527
- if (Array.isArray(filter?.status)) statusFilter = filter.status;
1528
- else if (filter?.status) f.status = filter.status;
1529
- const rows = await this.engine.find("sys_approval_request", {
1530
- where: f,
1531
- limit: 500,
1532
- orderBy: [{ field: "updated_at", direction: "desc" }],
1707
+ /**
1708
+ * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
1709
+ * its run, submitter-only. Audits `resubmit` on the returned (round-N)
1710
+ * request and resumes the run from the revise wait node; traversal walks
1711
+ * the declared back-edge into the approval node, whose executor opens the
1712
+ * round-N+1 request fresh approver slate, record re-locks.
1713
+ */
1714
+ async resubmit(requestId, input, context) {
1715
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1716
+ const rawRows = await this.engine.find("sys_approval_request", {
1717
+ where: { id: requestId },
1718
+ limit: 1,
1719
+ context: SYSTEM_CTX
1720
+ });
1721
+ const raw = Array.isArray(rawRows) ? rawRows[0] : null;
1722
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1723
+ if (raw.status !== "returned") {
1724
+ throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
1725
+ }
1726
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1727
+ throw new Error("FORBIDDEN: only the submitter may resubmit");
1728
+ }
1729
+ await this.assertLatestForRun(raw);
1730
+ const colliding = await this.engine.find("sys_approval_request", {
1731
+ where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
1732
+ limit: 1,
1533
1733
  context: SYSTEM_CTX
1534
1734
  });
1535
- let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
1536
- if (statusFilter) list = list.filter((r) => statusFilter.includes(r.status));
1537
- if (filter?.approverId) {
1538
- const targets = (Array.isArray(filter.approverId) ? filter.approverId : [filter.approverId]).map((t) => String(t).trim()).filter(Boolean);
1539
- if (targets.length) {
1540
- list = list.filter((r) => {
1541
- const pending = r.pending_approvers ?? [];
1542
- return targets.some((t) => pending.includes(t));
1735
+ if (Array.isArray(colliding) && colliding[0]) {
1736
+ throw new Error(
1737
+ `DUPLICATE_REQUEST: another approval request is already pending on ${raw.object_name}/${raw.record_id} \u2014 resolve it before resubmitting`
1738
+ );
1739
+ }
1740
+ const org = raw.organization_id ?? null;
1741
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
1742
+ const runId = raw.flow_run_id ?? null;
1743
+ const now = this.clock.now().toISOString();
1744
+ await this.engine.insert("sys_approval_action", {
1745
+ id: uid("aact"),
1746
+ request_id: requestId,
1747
+ organization_id: org,
1748
+ step_name: nodeId,
1749
+ step_index: 0,
1750
+ action: "resubmit",
1751
+ actor_id: input.actorId,
1752
+ comment: input.comment ?? null,
1753
+ created_at: now
1754
+ }, { context: SYSTEM_CTX });
1755
+ let resumed = false;
1756
+ if (runId && typeof this.automation?.resume === "function") {
1757
+ try {
1758
+ await this.automation.resume(runId, {
1759
+ branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
1760
+ output: { resubmitted: true, requestId }
1761
+ });
1762
+ resumed = true;
1763
+ } catch (err) {
1764
+ this.logger?.warn?.("[approvals] resume after resubmit failed", {
1765
+ request: requestId,
1766
+ run: runId,
1767
+ error: err?.message ?? String(err)
1543
1768
  });
1544
1769
  }
1545
1770
  }
1546
- await this.enrichRows(list);
1547
- return list;
1771
+ const fresh = await this.getRequest(requestId, context);
1772
+ return { request: fresh, runId, resumed };
1548
1773
  }
1549
- async getRequest(requestId, context) {
1550
- if (!requestId) return null;
1551
- const where = { id: requestId };
1552
- const tenantOrg = context?.organizationId ?? context?.tenantId;
1553
- if (tenantOrg) where.organization_id = tenantOrg;
1774
+ /**
1775
+ * ADR-0044 guard: the flow's approval node must declare a `revise`
1776
+ * out-edge before send-back is allowed — the engine's branch-label fallback
1777
+ * (no matching label ALL out-edges) must never be reachable from a user
1778
+ * action.
1779
+ */
1780
+ async assertReviseEdge(raw, nodeId) {
1781
+ const processName = String(raw.process_name ?? "");
1782
+ const flowName = processName.startsWith("flow:") ? processName.slice("flow:".length) : void 0;
1783
+ if (!flowName || !nodeId || typeof this.automation?.getFlow !== "function") {
1784
+ throw new Error("VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)");
1785
+ }
1786
+ const flow = await this.automation.getFlow(flowName);
1787
+ const hasRevise = Array.isArray(flow?.edges) && flow.edges.some((e) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise);
1788
+ if (!hasRevise) {
1789
+ throw new Error(
1790
+ `VALIDATION_FAILED: approval node '${nodeId}' has no '${APPROVAL_BRANCH_LABELS.revise}' out-edge \u2014 the flow does not support send-back for revision`
1791
+ );
1792
+ }
1793
+ }
1794
+ /**
1795
+ * ADR-0044 guard: a `returned` request is only actionable (resubmit /
1796
+ * recall) while it is still the newest request on its run — a later round
1797
+ * or a later node's request supersedes it.
1798
+ */
1799
+ async assertLatestForRun(raw) {
1800
+ const runId = raw.flow_run_id;
1801
+ if (!runId) return;
1554
1802
  const rows = await this.engine.find("sys_approval_request", {
1555
- where,
1803
+ where: { flow_run_id: runId },
1804
+ orderBy: [{ field: "created_at", order: "desc" }],
1556
1805
  limit: 1,
1557
1806
  context: SYSTEM_CTX
1558
1807
  });
1559
- if (!Array.isArray(rows) || !rows[0]) return null;
1560
- const row = rowFromRequest(rows[0]);
1561
- await this.enrichRows([row]);
1562
- return row;
1563
- }
1564
- async listActions(requestId, context) {
1565
- if (!requestId) return [];
1566
- const req = await this.getRequest(requestId, context);
1567
- if (!req) return [];
1568
- const rows = await this.engine.find("sys_approval_action", {
1569
- where: { request_id: requestId },
1570
- limit: 500,
1571
- orderBy: [{ field: "created_at", direction: "asc" }],
1572
- context: SYSTEM_CTX
1573
- });
1574
- return Array.isArray(rows) ? rows.map(rowFromAction) : [];
1808
+ const latest = Array.isArray(rows) ? rows[0] : null;
1809
+ if (latest && String(latest.id) !== String(raw.id)) {
1810
+ throw new Error("INVALID_STATE: a newer approval request supersedes this one");
1811
+ }
1575
1812
  }
1576
- };
1577
-
1578
- // src/lifecycle-hooks.ts
1579
- var APPROVALS_HOOK_PACKAGE = "plugin-approvals:lock";
1813
+ // ── Thread interactions (no flow movement) ───────────────────
1814
+ /**
1815
+ * Hand a pending-approver slot to someone else. `from` defaults to the
1816
+ * actor itself; the actor must hold the slot being handed over (or be a
1817
+ * system caller). Audits `reassign` and notifies the new approver.
1818
+ */
1819
+ async reassign(requestId, input, context) {
1820
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1821
+ const to = String(input?.to ?? "").trim();
1822
+ if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
1823
+ const raw = await this.loadPendingRow(requestId);
1824
+ const pending = csvSplit(raw.pending_approvers);
1825
+ const from = String(input.from ?? input.actorId).trim();
1826
+ if (!pending.includes(from)) {
1827
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
1828
+ }
1829
+ if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
1830
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1831
+ }
1832
+ if (pending.includes(to)) {
1833
+ throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
1834
+ }
1835
+ const next = pending.map((a) => a === from ? to : a);
1836
+ const now = this.clock.now().toISOString();
1837
+ await this.engine.insert("sys_approval_action", {
1838
+ id: uid("aact"),
1839
+ request_id: requestId,
1840
+ organization_id: raw.organization_id ?? null,
1841
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
1842
+ step_index: 0,
1843
+ action: "reassign",
1844
+ actor_id: input.actorId,
1845
+ comment: input.comment ?? `${from} \u2192 ${to}`,
1846
+ created_at: now
1847
+ }, { context: SYSTEM_CTX });
1848
+ await this.engine.update("sys_approval_request", {
1849
+ id: requestId,
1850
+ pending_approvers: next.join(","),
1851
+ updated_at: now
1852
+ }, { context: SYSTEM_CTX });
1853
+ await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
1854
+ await this.notify({
1855
+ topic: "approval.reassigned",
1856
+ audience: [to],
1857
+ actorId: input.actorId,
1858
+ source: { object: "sys_approval_request", id: requestId },
1859
+ dedupKey: `approval-reassign-${requestId}-${to}`,
1860
+ payload: {
1861
+ title: "Approval handed to you",
1862
+ message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
1863
+ actionUrl: "/system/approvals"
1864
+ }
1865
+ });
1866
+ const fresh = await this.getRequest(requestId, context);
1867
+ return { request: fresh };
1868
+ }
1869
+ /**
1870
+ * Submitter nudge — notify every pending approver. Throttled to one
1871
+ * reminder per {@link REMIND_COOLDOWN_MS} per request.
1872
+ */
1873
+ async remind(requestId, input, context) {
1874
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1875
+ const raw = await this.loadPendingRow(requestId);
1876
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1877
+ throw new Error("FORBIDDEN: only the submitter may send reminders");
1878
+ }
1879
+ const acts = await this.engine.find("sys_approval_action", {
1880
+ where: { request_id: requestId, action: "remind" },
1881
+ orderBy: [{ field: "created_at", order: "desc" }],
1882
+ limit: 1,
1883
+ context: SYSTEM_CTX
1884
+ });
1885
+ const last = Array.isArray(acts) ? acts[0] : null;
1886
+ const now = this.clock.now();
1887
+ if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) {
1888
+ throw new Error("THROTTLED: a reminder was already sent recently");
1889
+ }
1890
+ const pending = csvSplit(raw.pending_approvers);
1891
+ const nowIso = now.toISOString();
1892
+ await this.engine.insert("sys_approval_action", {
1893
+ id: uid("aact"),
1894
+ request_id: requestId,
1895
+ organization_id: raw.organization_id ?? null,
1896
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
1897
+ step_index: 0,
1898
+ action: "remind",
1899
+ actor_id: input.actorId,
1900
+ comment: input.comment ?? null,
1901
+ created_at: nowIso
1902
+ }, { context: SYSTEM_CTX });
1903
+ let notified = 0;
1904
+ const concrete = pending.filter((a) => a && !a.includes(":"));
1905
+ const literals = pending.filter((a) => a && a.includes(":"));
1906
+ for (const approver of concrete) {
1907
+ try {
1908
+ const tokens = await this.issueActionTokens(requestId, approver);
1909
+ notified += await this.notify({
1910
+ topic: "approval.reminder",
1911
+ audience: [approver],
1912
+ actorId: input.actorId,
1913
+ source: { object: "sys_approval_request", id: requestId },
1914
+ dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
1915
+ payload: {
1916
+ title: "Approval reminder",
1917
+ message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1918
+ actionUrl: "/system/approvals",
1919
+ actions: [
1920
+ { label: "Approve", url: this.actionLinkUrl(tokens.approve) },
1921
+ { label: "Reject", url: this.actionLinkUrl(tokens.reject) }
1922
+ ]
1923
+ }
1924
+ });
1925
+ } catch (err) {
1926
+ this.logger?.warn?.("[approvals] reminder with action links failed", {
1927
+ request: requestId,
1928
+ approver,
1929
+ error: err?.message ?? String(err)
1930
+ });
1931
+ }
1932
+ }
1933
+ if (literals.length) {
1934
+ notified += await this.notify({
1935
+ topic: "approval.reminder",
1936
+ audience: literals,
1937
+ actorId: input.actorId,
1938
+ source: { object: "sys_approval_request", id: requestId },
1939
+ dedupKey: `approval-remind-${requestId}-${nowIso}`,
1940
+ payload: {
1941
+ title: "Approval reminder",
1942
+ message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1943
+ actionUrl: "/system/approvals"
1944
+ }
1945
+ });
1946
+ }
1947
+ const fresh = await this.getRequest(requestId, context);
1948
+ return { request: fresh, notified };
1949
+ }
1950
+ // ── Actionable links (ADR-0043) ──────────────────────────────
1951
+ /** Build the session-less confirm-page URL for a raw token. */
1952
+ actionLinkUrl(rawToken) {
1953
+ return `${this.publicBaseUrl}/api/v1/approvals/act?token=${encodeURIComponent(rawToken)}`;
1954
+ }
1955
+ /**
1956
+ * Issue one-tap approve/reject tokens for one approver on one pending
1957
+ * request. Raw tokens are returned ONCE; only SHA-256 hashes are stored
1958
+ * (`sys_approval_token`), so a DB leak yields no usable links.
1959
+ */
1960
+ async issueActionTokens(requestId, approverId, opts) {
1961
+ if (!approverId?.trim()) throw new Error("VALIDATION_FAILED: approverId is required");
1962
+ const raw = await this.loadPendingRow(requestId);
1963
+ const pending = csvSplit(raw.pending_approvers);
1964
+ if (!pending.includes(approverId)) {
1965
+ throw new Error(`FORBIDDEN: '${approverId}' is not a pending approver on this request`);
1966
+ }
1967
+ const now = this.clock.now();
1968
+ const expires = new Date(now.getTime() + (opts?.ttlMs ?? ACTION_TOKEN_TTL_MS)).toISOString();
1969
+ const out = { approve: "", reject: "" };
1970
+ for (const action of ["approve", "reject"]) {
1971
+ const rawToken = randomBytes(32).toString("base64url");
1972
+ await this.engine.insert("sys_approval_token", {
1973
+ id: uid("atok"),
1974
+ organization_id: raw.organization_id ?? null,
1975
+ token_hash: createHash("sha256").update(rawToken).digest("hex"),
1976
+ request_id: requestId,
1977
+ action,
1978
+ approver_id: approverId,
1979
+ expires_at: expires,
1980
+ consumed_at: null,
1981
+ created_at: now.toISOString()
1982
+ }, { context: SYSTEM_CTX });
1983
+ out[action] = rawToken;
1984
+ }
1985
+ return out;
1986
+ }
1987
+ /** Shared validation chain for peek/redeem. Returns the token row when live. */
1988
+ async resolveActionToken(rawToken) {
1989
+ const trimmed = rawToken?.trim();
1990
+ if (!trimmed) return { ok: false, reason: "invalid" };
1991
+ const hash = createHash("sha256").update(trimmed).digest("hex");
1992
+ const rows = await this.engine.find("sys_approval_token", {
1993
+ where: { token_hash: hash },
1994
+ limit: 1,
1995
+ context: SYSTEM_CTX
1996
+ });
1997
+ const token = Array.isArray(rows) ? rows[0] : null;
1998
+ if (!token) return { ok: false, reason: "invalid" };
1999
+ if (token.consumed_at) return { ok: false, reason: "consumed" };
2000
+ if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2001
+ return { ok: false, reason: "expired" };
2002
+ }
2003
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX);
2004
+ if (!request || request.status !== "pending") {
2005
+ return { ok: false, reason: "not_pending", request: request ?? void 0 };
2006
+ }
2007
+ if (!(request.pending_approvers ?? []).includes(token.approver_id)) {
2008
+ return { ok: false, reason: "not_approver", request };
2009
+ }
2010
+ return { ok: true, token, request };
2011
+ }
2012
+ /** GET confirm page: validate WITHOUT consuming — never mutates. */
2013
+ async peekActionToken(rawToken) {
2014
+ const res = await this.resolveActionToken(rawToken);
2015
+ if (!res.ok) return res;
2016
+ return { ok: true, action: res.token.action, request: res.request, approverId: res.token.approver_id };
2017
+ }
2018
+ /**
2019
+ * POST redemption: consume the token FIRST (a failed decide still burns
2020
+ * it — replay-safe), then decide as the bound approver.
2021
+ */
2022
+ async redeemActionToken(rawToken) {
2023
+ const res = await this.resolveActionToken(rawToken);
2024
+ if (!res.ok) return res;
2025
+ await this.engine.update("sys_approval_token", {
2026
+ id: res.token.id,
2027
+ consumed_at: this.clock.now().toISOString()
2028
+ }, { context: SYSTEM_CTX });
2029
+ const out = await this.decide(res.token.request_id, {
2030
+ decision: res.token.action,
2031
+ actorId: res.token.approver_id,
2032
+ comment: "Via action link"
2033
+ }, SYSTEM_CTX);
2034
+ return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2035
+ }
2036
+ /**
2037
+ * Approver asks the submitter for more information. The request stays
2038
+ * pending — a thread interaction, not a flow decision.
2039
+ */
2040
+ async requestInfo(requestId, input, context) {
2041
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2042
+ if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2043
+ const raw = await this.loadPendingRow(requestId);
2044
+ const pending = csvSplit(raw.pending_approvers);
2045
+ if (!context.isSystem && !pending.includes(input.actorId)) {
2046
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
2047
+ }
2048
+ const now = this.clock.now().toISOString();
2049
+ await this.engine.insert("sys_approval_action", {
2050
+ id: uid("aact"),
2051
+ request_id: requestId,
2052
+ organization_id: raw.organization_id ?? null,
2053
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
2054
+ step_index: 0,
2055
+ action: "request_info",
2056
+ actor_id: input.actorId,
2057
+ comment: input.comment.trim(),
2058
+ created_at: now
2059
+ }, { context: SYSTEM_CTX });
2060
+ if (raw.submitter_id) {
2061
+ await this.notify({
2062
+ topic: "approval.request_info",
2063
+ audience: [String(raw.submitter_id)],
2064
+ actorId: input.actorId,
2065
+ source: { object: "sys_approval_request", id: requestId },
2066
+ payload: {
2067
+ title: "More information requested",
2068
+ message: input.comment.trim(),
2069
+ actionUrl: "/system/approvals"
2070
+ }
2071
+ });
2072
+ }
2073
+ const fresh = await this.getRequest(requestId, context);
2074
+ return { request: fresh };
2075
+ }
2076
+ /** Free-form reply on the thread (submitter or any pending approver). */
2077
+ async comment(requestId, input, context) {
2078
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2079
+ if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2080
+ const raw = await this.loadPendingRow(requestId);
2081
+ const pending = csvSplit(raw.pending_approvers);
2082
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
2083
+ if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
2084
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
2085
+ }
2086
+ const now = this.clock.now().toISOString();
2087
+ await this.engine.insert("sys_approval_action", {
2088
+ id: uid("aact"),
2089
+ request_id: requestId,
2090
+ organization_id: raw.organization_id ?? null,
2091
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
2092
+ step_index: 0,
2093
+ action: "comment",
2094
+ actor_id: input.actorId,
2095
+ comment: input.comment.trim(),
2096
+ created_at: now
2097
+ }, { context: SYSTEM_CTX });
2098
+ const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
2099
+ await this.notify({
2100
+ topic: "approval.comment",
2101
+ audience,
2102
+ actorId: input.actorId,
2103
+ source: { object: "sys_approval_request", id: requestId },
2104
+ payload: {
2105
+ title: "New comment on an approval",
2106
+ message: input.comment.trim(),
2107
+ actionUrl: "/system/approvals"
2108
+ }
2109
+ });
2110
+ const fresh = await this.getRequest(requestId, context);
2111
+ return { request: fresh };
2112
+ }
2113
+ // ── SLA escalation (ADR-0042) ─────────────────────────────────
2114
+ /**
2115
+ * One escalation sweep: every *pending* request whose node config declares
2116
+ * `escalation.timeoutHours` and whose deadline has passed is escalated
2117
+ * **at most once, ever** — the `escalate` audit row is the idempotency
2118
+ * marker, written before any mutation (audit-first, like reassign). One
2119
+ * bad row never stops the sweep.
2120
+ */
2121
+ async runEscalations() {
2122
+ let rows = [];
2123
+ try {
2124
+ rows = await this.engine.find("sys_approval_request", {
2125
+ where: { status: "pending" },
2126
+ limit: 500,
2127
+ context: SYSTEM_CTX
2128
+ }) ?? [];
2129
+ } catch (err) {
2130
+ this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
2131
+ error: err?.message ?? String(err)
2132
+ });
2133
+ return { scanned: 0, escalated: 0 };
2134
+ }
2135
+ let escalated = 0;
2136
+ for (const raw of rows) {
2137
+ try {
2138
+ const cfg = parseJson(raw.node_config_json, void 0);
2139
+ const esc2 = cfg?.escalation;
2140
+ if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
2141
+ const due = slaDueAt(raw.created_at, cfg);
2142
+ if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
2143
+ const prior = await this.engine.find("sys_approval_action", {
2144
+ where: { request_id: raw.id, action: "escalate" },
2145
+ limit: 1,
2146
+ context: SYSTEM_CTX
2147
+ });
2148
+ if (Array.isArray(prior) && prior[0]) continue;
2149
+ await this.escalateRequest(raw, esc2);
2150
+ escalated++;
2151
+ } catch (err) {
2152
+ this.logger?.warn?.("[approvals] escalation failed for request", {
2153
+ request: raw?.id,
2154
+ error: err?.message ?? String(err)
2155
+ });
2156
+ }
2157
+ }
2158
+ if (escalated > 0) {
2159
+ this.logger?.info?.("[approvals] SLA escalation sweep", { scanned: rows.length, escalated });
2160
+ }
2161
+ return { scanned: rows.length, escalated };
2162
+ }
2163
+ /** Execute the configured escalation action for one overdue request. */
2164
+ async escalateRequest(raw, esc2) {
2165
+ const action = esc2.action ?? "notify";
2166
+ const escalateTo = typeof esc2.escalateTo === "string" && esc2.escalateTo.trim() ? esc2.escalateTo.trim() : void 0;
2167
+ const now = this.clock.now().toISOString();
2168
+ const pending = csvSplit(raw.pending_approvers);
2169
+ await this.engine.insert("sys_approval_action", {
2170
+ id: uid("aact"),
2171
+ request_id: raw.id,
2172
+ organization_id: raw.organization_id ?? null,
2173
+ step_name: raw.flow_node_id ?? raw.current_step ?? null,
2174
+ step_index: 0,
2175
+ action: "escalate",
2176
+ actor_id: SLA_ACTOR_ID,
2177
+ comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
2178
+ created_at: now
2179
+ }, { context: SYSTEM_CTX });
2180
+ if (action === "reassign" && escalateTo) {
2181
+ await this.engine.update("sys_approval_request", {
2182
+ id: raw.id,
2183
+ pending_approvers: escalateTo,
2184
+ updated_at: now
2185
+ }, { context: SYSTEM_CTX });
2186
+ await this.syncApproverIndex(raw.id, [escalateTo], raw.organization_id ?? null, now);
2187
+ await this.notify({
2188
+ topic: "approval.escalated",
2189
+ audience: [escalateTo],
2190
+ actorId: SLA_ACTOR_ID,
2191
+ source: { object: "sys_approval_request", id: raw.id },
2192
+ payload: {
2193
+ title: "Approval escalated to you",
2194
+ message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
2195
+ actionUrl: "/system/approvals"
2196
+ }
2197
+ });
2198
+ } else if (action === "auto_approve" || action === "auto_reject") {
2199
+ await this.decide(raw.id, {
2200
+ decision: action === "auto_approve" ? "approve" : "reject",
2201
+ actorId: SLA_ACTOR_ID,
2202
+ comment: "SLA escalation"
2203
+ }, SYSTEM_CTX);
2204
+ } else {
2205
+ await this.notify({
2206
+ topic: "approval.sla_breached",
2207
+ audience: [...pending, ...escalateTo ? [escalateTo] : []],
2208
+ actorId: SLA_ACTOR_ID,
2209
+ source: { object: "sys_approval_request", id: raw.id },
2210
+ payload: {
2211
+ title: "Approval SLA breached",
2212
+ message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
2213
+ actionUrl: "/system/approvals"
2214
+ }
2215
+ });
2216
+ }
2217
+ if (esc2.notifySubmitter !== false && raw.submitter_id) {
2218
+ await this.notify({
2219
+ topic: "approval.sla_breached",
2220
+ audience: [String(raw.submitter_id)],
2221
+ actorId: SLA_ACTOR_ID,
2222
+ source: { object: "sys_approval_request", id: raw.id },
2223
+ payload: {
2224
+ title: "Your approval request breached its SLA",
2225
+ message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
2226
+ actionUrl: "/system/approvals"
2227
+ }
2228
+ });
2229
+ }
2230
+ }
2231
+ // ── Display enrichment ───────────────────────────────────────
2232
+ /**
2233
+ * Resolve the schema-declared display field for an object, when the engine
2234
+ * exposes schema metadata (`getSchema`). Falls back to common title-ish
2235
+ * field names so plain `ApprovalEngine` fakes still enrich sensibly.
2236
+ */
2237
+ resolveDisplayField(object) {
2238
+ try {
2239
+ const schema = this.engine.getSchema?.(object);
2240
+ const fields = schema?.fields ?? {};
2241
+ const declared = schema?.displayNameField;
2242
+ if (declared && declared !== "id" && fields[declared]) return declared;
2243
+ for (const cand of ["name", "title", "subject", "label"]) {
2244
+ if (fields[cand]) return cand;
2245
+ }
2246
+ } catch {
2247
+ }
2248
+ return void 0;
2249
+ }
2250
+ static pickTitle(rec, displayField) {
2251
+ const candidates = displayField ? [displayField, "name", "title", "subject", "label"] : ["name", "title", "subject", "label"];
2252
+ for (const f of candidates) {
2253
+ const v = rec?.[f];
2254
+ if (v != null && String(v).trim() && f !== "id") return String(v);
2255
+ }
2256
+ return void 0;
2257
+ }
2258
+ /**
2259
+ * Batch-resolve `sys_user` display names for identifiers that may be user
2260
+ * ids or emails. Best-effort — failures leave entries unresolved.
2261
+ */
2262
+ async resolveUserNames(identifiers) {
2263
+ const names = /* @__PURE__ */ new Map();
2264
+ const targets = Array.from(new Set(identifiers.filter(Boolean)));
2265
+ if (!targets.length) return names;
2266
+ try {
2267
+ const users = await this.engine.find("sys_user", {
2268
+ where: { id: { $in: targets } },
2269
+ fields: ["id", "name", "email"],
2270
+ limit: targets.length,
2271
+ context: SYSTEM_CTX
2272
+ });
2273
+ for (const u of users ?? []) {
2274
+ if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
2275
+ }
2276
+ } catch {
2277
+ }
2278
+ const unresolvedEmails = targets.filter((t) => !names.has(t) && t.includes("@"));
2279
+ if (unresolvedEmails.length) {
2280
+ try {
2281
+ const users = await this.engine.find("sys_user", {
2282
+ where: { email: { $in: unresolvedEmails } },
2283
+ fields: ["email", "name"],
2284
+ limit: unresolvedEmails.length,
2285
+ context: SYSTEM_CTX
2286
+ });
2287
+ for (const u of users ?? []) {
2288
+ if (u?.email && u.name) names.set(String(u.email), String(u.name));
2289
+ }
2290
+ } catch {
2291
+ }
2292
+ }
2293
+ return names;
2294
+ }
2295
+ /** Lookup-typed fields (key + referenced object) of an object's schema. */
2296
+ resolveLookupFields(object) {
2297
+ try {
2298
+ const schema = this.engine.getSchema?.(object);
2299
+ const fields = schema?.fields ?? {};
2300
+ const out = [];
2301
+ for (const [key, f] of Object.entries(fields)) {
2302
+ if ((f?.type === "lookup" || f?.type === "master_detail") && f?.reference) {
2303
+ out.push({ key, reference: String(f.reference) });
2304
+ }
2305
+ }
2306
+ return out;
2307
+ } catch {
2308
+ return [];
2309
+ }
2310
+ }
2311
+ /**
2312
+ * Attach inbox display fields to rows so clients never render a raw
2313
+ * identifier: `record_title`, `submitter_name`, `object_label`,
2314
+ * `pending_approver_names` (user-id approvers), and `payload_display`
2315
+ * (lookup foreign keys in the snapshot → referenced record titles).
2316
+ * Batched: one query per distinct object (target + referenced) plus one
2317
+ * `sys_user` lookup. Best-effort — a deleted record falls back to the
2318
+ * payload snapshot, and any failure leaves the field unset rather than
2319
+ * failing the list.
2320
+ */
2321
+ async enrichRows(rows) {
2322
+ if (!rows.length) return;
2323
+ const byObject = /* @__PURE__ */ new Map();
2324
+ for (const r of rows) {
2325
+ if (!r.object_name || !r.record_id) continue;
2326
+ let set = byObject.get(r.object_name);
2327
+ if (!set) {
2328
+ set = /* @__PURE__ */ new Set();
2329
+ byObject.set(r.object_name, set);
2330
+ }
2331
+ set.add(r.record_id);
2332
+ }
2333
+ const titles = /* @__PURE__ */ new Map();
2334
+ const objectLabels = /* @__PURE__ */ new Map();
2335
+ for (const [object, idSet] of byObject) {
2336
+ try {
2337
+ const schema = this.engine.getSchema?.(object);
2338
+ if (schema?.label) objectLabels.set(object, String(schema.label));
2339
+ } catch {
2340
+ }
2341
+ const ids = Array.from(idSet);
2342
+ const displayField = this.resolveDisplayField(object);
2343
+ try {
2344
+ const recs = await this.engine.find(object, {
2345
+ where: { id: { $in: ids } },
2346
+ limit: ids.length,
2347
+ context: SYSTEM_CTX
2348
+ });
2349
+ for (const rec of recs ?? []) {
2350
+ const title = _ApprovalService.pickTitle(rec, displayField);
2351
+ if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
2352
+ }
2353
+ } catch {
2354
+ }
2355
+ }
2356
+ const lookupFieldsByObject = /* @__PURE__ */ new Map();
2357
+ for (const object of byObject.keys()) {
2358
+ const lookups = this.resolveLookupFields(object);
2359
+ if (lookups.length) lookupFieldsByObject.set(object, lookups);
2360
+ }
2361
+ const refIds = /* @__PURE__ */ new Map();
2362
+ for (const r of rows) {
2363
+ const lookups = lookupFieldsByObject.get(r.object_name);
2364
+ const payload = r.payload;
2365
+ if (!lookups || !payload || typeof payload !== "object") continue;
2366
+ for (const { key, reference } of lookups) {
2367
+ const v = payload[key];
2368
+ if (v == null || typeof v === "object" || !String(v).trim()) continue;
2369
+ let set = refIds.get(reference);
2370
+ if (!set) {
2371
+ set = /* @__PURE__ */ new Set();
2372
+ refIds.set(reference, set);
2373
+ }
2374
+ set.add(String(v));
2375
+ }
2376
+ }
2377
+ const refTitles = /* @__PURE__ */ new Map();
2378
+ for (const [object, idSet] of refIds) {
2379
+ const ids = Array.from(idSet);
2380
+ const displayField = this.resolveDisplayField(object);
2381
+ try {
2382
+ const recs = await this.engine.find(object, {
2383
+ where: { id: { $in: ids } },
2384
+ limit: ids.length,
2385
+ context: SYSTEM_CTX
2386
+ });
2387
+ for (const rec of recs ?? []) {
2388
+ const title = _ApprovalService.pickTitle(rec, displayField);
2389
+ if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
2390
+ }
2391
+ } catch {
2392
+ }
2393
+ }
2394
+ const userIdentifiers = [];
2395
+ for (const r of rows) {
2396
+ userIdentifiers.push(r.submitter_id);
2397
+ for (const a of r.pending_approvers ?? []) {
2398
+ if (a && !a.includes(":")) userIdentifiers.push(a);
2399
+ }
2400
+ }
2401
+ const names = await this.resolveUserNames(userIdentifiers);
2402
+ for (const r of rows) {
2403
+ const title = titles.get(`${r.object_name} ${r.record_id}`) ?? _ApprovalService.pickTitle(r.payload, void 0);
2404
+ if (title) r.record_title = title;
2405
+ const name = r.submitter_id ? names.get(String(r.submitter_id)) : void 0;
2406
+ if (name) r.submitter_name = name;
2407
+ const label = objectLabels.get(r.object_name);
2408
+ if (label) r.object_label = label;
2409
+ const approverNames = {};
2410
+ for (const a of r.pending_approvers ?? []) {
2411
+ const n = names.get(String(a));
2412
+ if (n) approverNames[a] = n;
2413
+ }
2414
+ if (Object.keys(approverNames).length) r.pending_approver_names = approverNames;
2415
+ const lookups = lookupFieldsByObject.get(r.object_name);
2416
+ if (lookups && r.payload && typeof r.payload === "object") {
2417
+ const display = {};
2418
+ for (const { key, reference } of lookups) {
2419
+ const v = r.payload[key];
2420
+ if (v == null) continue;
2421
+ const t = refTitles.get(`${reference} ${String(v)}`);
2422
+ if (t) display[key] = t;
2423
+ }
2424
+ if (Object.keys(display).length) r.payload_display = display;
2425
+ }
2426
+ }
2427
+ }
2428
+ // ── Pending-approver index (issue #1745) ─────────────────────
2429
+ /**
2430
+ * Mirror one request's `pending_approvers` CSV into the normalized
2431
+ * `sys_approval_approver` index. Called by every write path that changes
2432
+ * the approver set; an empty `approvers` clears the request's rows (the
2433
+ * request left `pending`). Diff-based so reassign/unanimous churn doesn't
2434
+ * rewrite untouched rows.
2435
+ */
2436
+ async syncApproverIndex(requestId, approvers, org, now) {
2437
+ const desired = new Set(approvers.map((a) => String(a).trim()).filter(Boolean));
2438
+ const existing = await this.engine.find("sys_approval_approver", {
2439
+ where: { request_id: requestId },
2440
+ limit: 500,
2441
+ context: SYSTEM_CTX
2442
+ });
2443
+ const rows = Array.isArray(existing) ? existing : [];
2444
+ for (const row of rows) {
2445
+ if (desired.has(String(row.approver))) desired.delete(String(row.approver));
2446
+ else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
2447
+ }
2448
+ for (const approver of desired) {
2449
+ await this.engine.insert("sys_approval_approver", {
2450
+ id: uid("aapr"),
2451
+ request_id: requestId,
2452
+ approver,
2453
+ organization_id: org,
2454
+ created_at: now
2455
+ }, { context: SYSTEM_CTX });
2456
+ }
2457
+ }
2458
+ /**
2459
+ * Rebuild the whole `sys_approval_approver` index from the CSV source of
2460
+ * truth. Idempotent; run at plugin start so rows written before the index
2461
+ * existed (or drifted past a crashed sync) become queryable. Cost tracks
2462
+ * the number of *pending* requests, not the request history.
2463
+ */
2464
+ async rebuildApproverIndex() {
2465
+ const desired = /* @__PURE__ */ new Map();
2466
+ const PAGE = 500;
2467
+ for (let offset = 0; ; offset += PAGE) {
2468
+ const batch = await this.engine.find("sys_approval_request", {
2469
+ where: { status: "pending" },
2470
+ fields: ["id", "pending_approvers", "organization_id"],
2471
+ limit: PAGE,
2472
+ offset,
2473
+ context: SYSTEM_CTX
2474
+ });
2475
+ const rows = Array.isArray(batch) ? batch : [];
2476
+ for (const r of rows) {
2477
+ desired.set(String(r.id), {
2478
+ approvers: new Set(csvSplit(r.pending_approvers)),
2479
+ org: r.organization_id ?? null
2480
+ });
2481
+ }
2482
+ if (rows.length < PAGE) break;
2483
+ }
2484
+ const indexRows = [];
2485
+ for (let offset = 0; ; offset += PAGE) {
2486
+ const batch = await this.engine.find("sys_approval_approver", {
2487
+ orderBy: [{ field: "created_at", order: "asc" }],
2488
+ limit: PAGE,
2489
+ offset,
2490
+ context: SYSTEM_CTX
2491
+ });
2492
+ const rows = Array.isArray(batch) ? batch : [];
2493
+ indexRows.push(...rows);
2494
+ if (rows.length < PAGE) break;
2495
+ }
2496
+ let inserted = 0;
2497
+ let deleted = 0;
2498
+ const seen = /* @__PURE__ */ new Map();
2499
+ for (const row of indexRows) {
2500
+ const reqId = String(row.request_id);
2501
+ const want = desired.get(reqId);
2502
+ const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
2503
+ if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
2504
+ await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
2505
+ deleted++;
2506
+ continue;
2507
+ }
2508
+ have.add(String(row.approver));
2509
+ }
2510
+ const now = this.clock.now().toISOString();
2511
+ for (const [reqId, want] of desired) {
2512
+ const have = seen.get(reqId);
2513
+ for (const approver of want.approvers) {
2514
+ if (have?.has(approver)) continue;
2515
+ await this.engine.insert("sys_approval_approver", {
2516
+ id: uid("aapr"),
2517
+ request_id: reqId,
2518
+ approver,
2519
+ organization_id: want.org,
2520
+ created_at: now
2521
+ }, { context: SYSTEM_CTX });
2522
+ inserted++;
2523
+ }
2524
+ }
2525
+ return { requests: desired.size, inserted, deleted };
2526
+ }
2527
+ // ── Read API ─────────────────────────────────────────────────
2528
+ /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
2529
+ buildRequestWhere(filter, context) {
2530
+ const f = {};
2531
+ if (filter?.object) f.object_name = filter.object;
2532
+ if (filter?.recordId) f.record_id = filter.recordId;
2533
+ if (filter?.submitterId) f.submitter_id = filter.submitterId;
2534
+ const tenantOrg = context?.organizationId ?? context?.tenantId ?? null;
2535
+ if (tenantOrg) f.organization_id = tenantOrg;
2536
+ const q = filter?.q?.trim();
2537
+ if (q) {
2538
+ f.$or = [
2539
+ { process_name: { $contains: q } },
2540
+ { object_name: { $contains: q } },
2541
+ { record_id: { $contains: q } },
2542
+ { submitter_id: { $contains: q } },
2543
+ { payload_json: { $contains: q } }
2544
+ ];
2545
+ }
2546
+ if (Array.isArray(filter?.status)) {
2547
+ const statuses = filter.status.filter(Boolean);
2548
+ if (statuses.length === 1) f.status = statuses[0];
2549
+ else if (statuses.length > 1) f.status = { $in: statuses };
2550
+ } else if (filter?.status) {
2551
+ f.status = filter.status;
2552
+ }
2553
+ return { where: f, tenantOrg };
2554
+ }
2555
+ /**
2556
+ * Resolve an approver filter to matching request ids via the normalized
2557
+ * `sys_approval_approver` index — the indexed replacement for the old
2558
+ * in-memory CSV scan, and what makes approver-filtered pagination correct
2559
+ * past any scan window (issue #1745). A request matches when ANY of the
2560
+ * caller's identities (user id / email / role:<r>) holds a pending slot.
2561
+ * Returns null when the filter is absent (callers skip the id constraint).
2562
+ */
2563
+ async approverRequestIds(targets, tenantOrg) {
2564
+ if (!targets.length) return null;
2565
+ const where = targets.length === 1 ? { approver: targets[0] } : { approver: { $in: targets } };
2566
+ if (tenantOrg) where.organization_id = tenantOrg;
2567
+ const rows = await this.engine.find("sys_approval_approver", {
2568
+ where,
2569
+ fields: ["request_id"],
2570
+ limit: _ApprovalService.APPROVER_INDEX_CAP,
2571
+ context: SYSTEM_CTX
2572
+ });
2573
+ const list = Array.isArray(rows) ? rows : [];
2574
+ if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
2575
+ this.logger?.warn?.("[approvals] approver index probe hit its window \u2014 results may be truncated", {
2576
+ cap: _ApprovalService.APPROVER_INDEX_CAP,
2577
+ targets: targets.length
2578
+ });
2579
+ }
2580
+ return [...new Set(list.map((r) => String(r.request_id)))];
2581
+ }
2582
+ async listRequests(filter, context) {
2583
+ const { where, tenantOrg } = this.buildRequestWhere(filter, context);
2584
+ const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
2585
+ const ids = await this.approverRequestIds(approverTargets, tenantOrg);
2586
+ if (ids) {
2587
+ if (ids.length === 0) return [];
2588
+ where.id = ids.length === 1 ? ids[0] : { $in: ids };
2589
+ }
2590
+ const findOpts = {
2591
+ where,
2592
+ orderBy: [{ field: "created_at", order: "desc" }],
2593
+ context: SYSTEM_CTX
2594
+ };
2595
+ if (filter?.limit != null || filter?.offset != null) {
2596
+ findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
2597
+ if (filter?.offset) findOpts.offset = Math.max(filter.offset, 0);
2598
+ } else {
2599
+ findOpts.limit = 500;
2600
+ }
2601
+ const rows = await this.engine.find("sys_approval_request", findOpts);
2602
+ const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
2603
+ await this.enrichRows(list);
2604
+ return list;
2605
+ }
2606
+ async countRequests(filter, context) {
2607
+ const { where, tenantOrg } = this.buildRequestWhere(filter, context);
2608
+ const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
2609
+ const ids = await this.approverRequestIds(approverTargets, tenantOrg);
2610
+ if (ids) {
2611
+ if (ids.length === 0) return 0;
2612
+ where.id = ids.length === 1 ? ids[0] : { $in: ids };
2613
+ }
2614
+ const countFn = this.engine.count;
2615
+ if (typeof countFn === "function") {
2616
+ try {
2617
+ const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX });
2618
+ if (typeof n === "number") return n;
2619
+ } catch {
2620
+ }
2621
+ }
2622
+ const rows = await this.engine.find("sys_approval_request", {
2623
+ where,
2624
+ fields: ["id"],
2625
+ limit: ids ? Math.max(500, ids.length) : 500,
2626
+ context: SYSTEM_CTX
2627
+ });
2628
+ return Array.isArray(rows) ? rows.length : 0;
2629
+ }
2630
+ async getRequest(requestId, context) {
2631
+ if (!requestId) return null;
2632
+ const where = { id: requestId };
2633
+ const tenantOrg = context?.organizationId ?? context?.tenantId;
2634
+ if (tenantOrg) where.organization_id = tenantOrg;
2635
+ const rows = await this.engine.find("sys_approval_request", {
2636
+ where,
2637
+ limit: 1,
2638
+ context: SYSTEM_CTX
2639
+ });
2640
+ if (!Array.isArray(rows) || !rows[0]) return null;
2641
+ const row = rowFromRequest(rows[0]);
2642
+ await this.enrichRows([row]);
2643
+ await this.attachFlowSteps(row);
2644
+ return row;
2645
+ }
2646
+ /**
2647
+ * Derive approval-step progress from the owning flow's graph (single-read
2648
+ * enrichment only — list reads skip it). Walks from the start node
2649
+ * preferring `approve`/`true` edges, so the result is the flow's main
2650
+ * approval trunk; conditional side-steps show as part of the potential
2651
+ * path. Display-only and best-effort.
2652
+ */
2653
+ async attachFlowSteps(row) {
2654
+ try {
2655
+ const flowName = row.process_name?.startsWith("flow:") ? row.process_name.slice(5) : void 0;
2656
+ if (!flowName || typeof this.automation?.getFlow !== "function") return;
2657
+ const flow = await this.automation.getFlow(flowName);
2658
+ if (!flow?.nodes?.length) return;
2659
+ const nodesById = new Map(flow.nodes.map((n) => [n.id, n]));
2660
+ const steps = [];
2661
+ const seen = /* @__PURE__ */ new Set();
2662
+ let cur = flow.nodes.find((n) => n.type === "start");
2663
+ while (cur && !seen.has(cur.id)) {
2664
+ seen.add(cur.id);
2665
+ if (cur.type === "approval") steps.push({ id: cur.id, label: cur.label || cur.id });
2666
+ const out = (flow.edges ?? []).filter((e) => e.source === cur.id);
2667
+ if (!out.length) break;
2668
+ const pick = out.find((e) => e.label === "approve") ?? out.find((e) => e.label === "true") ?? out[0];
2669
+ cur = nodesById.get(pick.target);
2670
+ }
2671
+ if (steps.length === 0) return;
2672
+ const currentId = row.flow_node_id ?? row.current_step;
2673
+ const currentIdx = steps.findIndex((s) => s.id === currentId);
2674
+ row.flow_steps = steps.map((s, i) => ({
2675
+ ...s,
2676
+ state: currentIdx < 0 ? "upcoming" : i < currentIdx ? "done" : i === currentIdx ? row.status === "approved" ? "done" : "current" : "upcoming"
2677
+ }));
2678
+ } catch {
2679
+ }
2680
+ }
2681
+ async listActions(requestId, context) {
2682
+ if (!requestId) return [];
2683
+ const req = await this.getRequest(requestId, context);
2684
+ if (!req) return [];
2685
+ const rows = await this.engine.find("sys_approval_action", {
2686
+ where: { request_id: requestId },
2687
+ limit: 500,
2688
+ orderBy: [{ field: "created_at", order: "asc" }],
2689
+ context: SYSTEM_CTX
2690
+ });
2691
+ const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
2692
+ const names = await this.resolveUserNames(
2693
+ actions.map((a) => a.actor_id).filter((id) => id && !id.includes(":"))
2694
+ );
2695
+ for (const a of actions) {
2696
+ const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
2697
+ if (n) a.actor_name = n;
2698
+ }
2699
+ return actions;
2700
+ }
2701
+ };
2702
+ /** Window the approver-index probe — pending queues live far below this. */
2703
+ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
2704
+ var ApprovalService = _ApprovalService;
2705
+
2706
+ // src/sys-approval-token.object.ts
2707
+ import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
2708
+ var SysApprovalToken = ObjectSchema4.create({
2709
+ name: "sys_approval_token",
2710
+ label: "Approval Action Token",
2711
+ pluralLabel: "Approval Action Tokens",
2712
+ icon: "key",
2713
+ isSystem: true,
2714
+ managedBy: "system",
2715
+ description: "Single-use tokens behind actionable approval links",
2716
+ displayNameField: "id",
2717
+ fields: {
2718
+ id: Field4.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
2719
+ organization_id: Field4.lookup("sys_organization", {
2720
+ label: "Organization",
2721
+ required: false,
2722
+ group: "System"
2723
+ }),
2724
+ token_hash: Field4.text({
2725
+ label: "Token Hash",
2726
+ required: true,
2727
+ maxLength: 100,
2728
+ readonly: true,
2729
+ description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
2730
+ group: "Token"
2731
+ }),
2732
+ request_id: Field4.text({
2733
+ label: "Request",
2734
+ required: true,
2735
+ maxLength: 100,
2736
+ readonly: true,
2737
+ group: "Token"
2738
+ }),
2739
+ action: Field4.select(["approve", "reject"], {
2740
+ label: "Action",
2741
+ required: true,
2742
+ readonly: true,
2743
+ group: "Token"
2744
+ }),
2745
+ approver_id: Field4.text({
2746
+ label: "Approver",
2747
+ required: true,
2748
+ maxLength: 200,
2749
+ readonly: true,
2750
+ description: "Identity the token is bound to; the decision is audited as this approver",
2751
+ group: "Token"
2752
+ }),
2753
+ expires_at: Field4.datetime({
2754
+ label: "Expires At",
2755
+ required: true,
2756
+ readonly: true,
2757
+ group: "Lifecycle"
2758
+ }),
2759
+ consumed_at: Field4.datetime({
2760
+ label: "Consumed At",
2761
+ required: false,
2762
+ group: "Lifecycle"
2763
+ }),
2764
+ created_at: Field4.datetime({
2765
+ label: "Created At",
2766
+ required: true,
2767
+ defaultValue: "NOW()",
2768
+ readonly: true,
2769
+ group: "System"
2770
+ })
2771
+ },
2772
+ indexes: [
2773
+ { fields: ["token_hash"] },
2774
+ { fields: ["request_id"] }
2775
+ ]
2776
+ });
2777
+
2778
+ // src/action-link-pages.ts
2779
+ function esc(s) {
2780
+ return String(s ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
2781
+ }
2782
+ function shell(title, body) {
2783
+ return `<!doctype html>
2784
+ <html lang="en"><head><meta charset="utf-8">
2785
+ <meta name="viewport" content="width=device-width, initial-scale=1">
2786
+ <meta name="robots" content="noindex">
2787
+ <title>${esc(title)}</title>
2788
+ <style>
2789
+ body{font:15px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"PingFang SC","Microsoft YaHei",sans-serif;
2790
+ background:#f6f7f9;color:#1a202c;margin:0;display:flex;min-height:100vh;align-items:center;justify-content:center}
2791
+ .card{background:#fff;border:1px solid #e2e8f0;border-radius:12px;max-width:440px;width:calc(100% - 32px);
2792
+ padding:28px 32px;box-shadow:0 1px 3px rgba(0,0,0,.06)}
2793
+ h1{font-size:18px;margin:0 0 4px}
2794
+ .sub{color:#64748b;font-size:13px;margin:0 0 20px}
2795
+ .row{display:flex;justify-content:space-between;gap:12px;padding:7px 0;border-bottom:1px solid #f1f5f9;font-size:14px}
2796
+ .row b{font-weight:600;text-align:right}
2797
+ .k{color:#64748b}
2798
+ .actions{margin-top:22px;display:flex;gap:10px}
2799
+ button{flex:1;padding:10px 16px;border-radius:8px;border:1px solid transparent;font-size:15px;font-weight:600;cursor:pointer}
2800
+ .approve{background:#059669;color:#fff}
2801
+ .reject{background:#fff;color:#dc2626;border-color:#fca5a5}
2802
+ .badge{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;font-weight:600;margin-bottom:14px}
2803
+ .ok{background:#ecfdf5;color:#047857}.warn{background:#fffbeb;color:#b45309}.err{background:#fef2f2;color:#b91c1c}
2804
+ a{color:#2563eb;text-decoration:none}
2805
+ .foot{margin-top:18px;font-size:12px;color:#94a3b8}
2806
+ </style></head><body><div class="card">${body}</div></body></html>`;
2807
+ }
2808
+ function summaryRows(req) {
2809
+ const rows = [
2810
+ ["Process \xB7 \u6D41\u7A0B", req.process_label || req.process_name],
2811
+ ["Step \xB7 \u6B65\u9AA4", req.step_label || req.current_step || "\u2014"],
2812
+ ["Record \xB7 \u8BB0\u5F55", req.record_title || req.record_id],
2813
+ ["Object \xB7 \u5BF9\u8C61", req.object_label || req.object_name],
2814
+ ["Requester \xB7 \u7533\u8BF7\u4EBA", req.submitter_name || req.submitter_id || "\u2014"]
2815
+ ];
2816
+ return rows.map(([k, v]) => `<div class="row"><span class="k">${esc(k)}</span><b>${esc(v)}</b></div>`).join("");
2817
+ }
2818
+ function renderConfirmPage(input) {
2819
+ const approving = input.action === "approve";
2820
+ const verb = approving ? "Approve \xB7 \u901A\u8FC7" : "Reject \xB7 \u62D2\u7EDD";
2821
+ return shell(`${verb} \u2014 Approval`, `
2822
+ <h1>${approving ? "\u2705 Approve this request?" : "\u26D4 Reject this request?"}</h1>
2823
+ <p class="sub">${approving ? "\u786E\u8BA4\u901A\u8FC7\u8BE5\u5BA1\u6279\u8BF7\u6C42\uFF1F" : "\u786E\u8BA4\u62D2\u7EDD\u8BE5\u5BA1\u6279\u8BF7\u6C42\uFF1F"}
2824
+ Acting as \xB7 \u64CD\u4F5C\u8EAB\u4EFD\uFF1A<b>${esc(input.approverId)}</b></p>
2825
+ ${summaryRows(input.request)}
2826
+ <form method="post" action="${esc(input.actPath)}" class="actions">
2827
+ <input type="hidden" name="token" value="${esc(input.token)}">
2828
+ <button type="submit" class="${approving ? "approve" : "reject"}">${verb}</button>
2829
+ </form>
2830
+ <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>`);
2831
+ }
2832
+ var RESULT_COPY = {
2833
+ approved: { cls: "ok", title: "\u2705 Approved \xB7 \u5DF2\u901A\u8FC7", body: "The decision was recorded. \xB7 \u5BA1\u6279\u7ED3\u679C\u5DF2\u8BB0\u5F55\u3002" },
2834
+ rejected: { cls: "ok", title: "\u26D4 Rejected \xB7 \u5DF2\u62D2\u7EDD", body: "The decision was recorded. \xB7 \u5BA1\u6279\u7ED3\u679C\u5DF2\u8BB0\u5F55\u3002" },
2835
+ 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" },
2836
+ 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" },
2837
+ 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" },
2838
+ 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" },
2839
+ 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" }
2840
+ };
2841
+ function renderResultPage(kind, request) {
2842
+ const copy = RESULT_COPY[kind] ?? RESULT_COPY.invalid;
2843
+ return shell(copy.title, `
2844
+ <span class="badge ${copy.cls}">${esc(copy.title)}</span>
2845
+ ${request ? summaryRows(request) : ""}
2846
+ <p>${esc(copy.body)}</p>
2847
+ <p class="foot"><a href="/system/approvals">Open the Approvals Inbox \xB7 \u6253\u5F00\u5BA1\u6279\u4E2D\u5FC3</a></p>`);
2848
+ }
2849
+
2850
+ // src/lifecycle-hooks.ts
2851
+ var APPROVALS_HOOK_PACKAGE = "plugin-approvals:lock";
1580
2852
  function parseJson2(raw, fallback) {
1581
2853
  if (raw == null || raw === "") return fallback;
1582
2854
  if (typeof raw === "string") {
@@ -1712,6 +2984,7 @@ var ApprovalsServicePlugin = class {
1712
2984
  this.version = "1.0.0";
1713
2985
  this.type = "standard";
1714
2986
  this.dependencies = ["com.objectstack.engine.objectql"];
2987
+ this.escalationJobScheduled = false;
1715
2988
  this.options = options;
1716
2989
  }
1717
2990
  async init(ctx) {
@@ -1723,7 +2996,7 @@ var ApprovalsServicePlugin = class {
1723
2996
  scope: "system",
1724
2997
  defaultDatasource: "cloud",
1725
2998
  namespace: "sys",
1726
- objects: [SysApprovalRequest, SysApprovalAction],
2999
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
1727
3000
  // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
1728
3001
  // `group_approvals` slot. This plugin owns these objects (K2.b), so it
1729
3002
  // ships their menu too; when the plugin isn't installed the slot is empty.
@@ -1773,7 +3046,8 @@ var ApprovalsServicePlugin = class {
1773
3046
  this.engine = engine;
1774
3047
  this.service = new ApprovalService({
1775
3048
  engine,
1776
- logger: ctx.logger
3049
+ logger: ctx.logger,
3050
+ publicBaseUrl: this.options.publicBaseUrl
1777
3051
  });
1778
3052
  if (!this.options.disableAutoHooks) {
1779
3053
  try {
@@ -1785,6 +3059,86 @@ var ApprovalsServicePlugin = class {
1785
3059
  }
1786
3060
  ctx.registerService("approvals", this.service);
1787
3061
  ctx.logger.info("ApprovalsServicePlugin: service registered");
3062
+ try {
3063
+ const messaging = ctx.getService("messaging");
3064
+ if (messaging && typeof messaging.emit === "function") {
3065
+ this.service.attachMessaging(messaging);
3066
+ }
3067
+ } catch {
3068
+ }
3069
+ const wireEscalationClock = async () => {
3070
+ try {
3071
+ const jobs = ctx.getService("job");
3072
+ if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
3073
+ const svc = this.service;
3074
+ const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
3075
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, async () => {
3076
+ await svc.runEscalations();
3077
+ });
3078
+ this.escalationJobScheduled = true;
3079
+ void svc.runEscalations().catch((err) => {
3080
+ ctx.logger.warn?.("[approvals] boot escalation sweep failed", { error: err?.message });
3081
+ });
3082
+ ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
3083
+ } catch {
3084
+ }
3085
+ };
3086
+ const mountActionPages = async () => {
3087
+ try {
3088
+ const http = ctx.getService("http-server");
3089
+ const rawApp = http && typeof http.getRawApp === "function" ? http.getRawApp() : null;
3090
+ if (!rawApp || !this.service) return;
3091
+ const svc = this.service;
3092
+ const ACT_PATH = "/api/v1/approvals/act";
3093
+ const html = (c, body, status = 200) => c.body(body, status, { "Content-Type": "text/html; charset=utf-8" });
3094
+ rawApp.get(ACT_PATH, async (c) => {
3095
+ const token = String(c.req.query("token") ?? "");
3096
+ const peek = await svc.peekActionToken(token);
3097
+ if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
3098
+ return html(c, renderConfirmPage({
3099
+ request: peek.request,
3100
+ action: peek.action,
3101
+ approverId: peek.approverId,
3102
+ token,
3103
+ actPath: ACT_PATH
3104
+ }));
3105
+ });
3106
+ rawApp.post(ACT_PATH, async (c) => {
3107
+ let token = "";
3108
+ try {
3109
+ const body = await c.req.parseBody();
3110
+ token = String(body?.token ?? "");
3111
+ } catch {
3112
+ }
3113
+ const out = await svc.redeemActionToken(token);
3114
+ if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
3115
+ return html(c, renderResultPage(out.action === "approve" ? "approved" : "rejected", out.request));
3116
+ });
3117
+ ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
3118
+ } catch {
3119
+ }
3120
+ };
3121
+ const backfillApproverIndex = async () => {
3122
+ try {
3123
+ const svc = this.service;
3124
+ if (!svc) return;
3125
+ const out = await svc.rebuildApproverIndex();
3126
+ if (out.inserted > 0 || out.deleted > 0) {
3127
+ ctx.logger.info("ApprovalsServicePlugin: approver index rebuilt", out);
3128
+ }
3129
+ } catch (err) {
3130
+ ctx.logger.warn?.("[approvals] approver index backfill failed", { error: err?.message });
3131
+ }
3132
+ };
3133
+ if (typeof ctx.hook === "function") {
3134
+ ctx.hook("kernel:ready", wireEscalationClock);
3135
+ ctx.hook("kernel:ready", mountActionPages);
3136
+ ctx.hook("kernel:ready", backfillApproverIndex);
3137
+ } else {
3138
+ await wireEscalationClock();
3139
+ await mountActionPages();
3140
+ await backfillApproverIndex();
3141
+ }
1788
3142
  try {
1789
3143
  const automation = ctx.getService("automation");
1790
3144
  if (automation && typeof automation.registerNodeExecutor === "function") {
@@ -1795,7 +3149,15 @@ var ApprovalsServicePlugin = class {
1795
3149
  ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
1796
3150
  }
1797
3151
  }
1798
- async stop(_ctx) {
3152
+ async stop(ctx) {
3153
+ if (this.escalationJobScheduled) {
3154
+ try {
3155
+ const jobs = ctx.getService("job");
3156
+ await jobs?.cancel?.(ESCALATION_JOB_NAME);
3157
+ } catch {
3158
+ }
3159
+ this.escalationJobScheduled = false;
3160
+ }
1799
3161
  if (this.engine) {
1800
3162
  try {
1801
3163
  unbindAllHooks(this.engine);
@@ -1808,6 +3170,7 @@ export {
1808
3170
  ApprovalService,
1809
3171
  ApprovalsServicePlugin,
1810
3172
  SysApprovalAction,
3173
+ SysApprovalApprover,
1811
3174
  SysApprovalRequest,
1812
3175
  registerApprovalNode
1813
3176
  };