@objectstack/plugin-approvals 9.2.0 → 9.4.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/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +43 -0
- package/dist/index.d.mts +2343 -177
- package/dist/index.d.ts +2343 -177
- package/dist/index.js +1293 -34
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1292 -34
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/action-link-pages.ts +102 -0
- package/src/approval-revise.test.ts +411 -0
- package/src/approval-service.test.ts +452 -4
- package/src/approval-service.ts +1128 -34
- package/src/approvals-plugin.ts +124 -3
- package/src/index.ts +1 -0
- package/src/nav-contribution.test.ts +3 -1
- package/src/sys-approval-action.object.ts +5 -1
- package/src/sys-approval-approver.object.ts +78 -0
- package/src/sys-approval-request.object.ts +8 -4
- package/src/sys-approval-token.object.ts +94 -0
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
|
-
|
|
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
|
-
//
|
|
851
|
-
//
|
|
852
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
1378
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
1645
|
-
if (
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
1654
|
-
if (
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
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",
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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(
|
|
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
|
});
|