@objectstack/plugin-approvals 9.2.0 → 9.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +27 -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.mjs
CHANGED
|
@@ -744,7 +744,9 @@ var SysApprovalRequest = ObjectSchema.create({
|
|
|
744
744
|
group: "Target"
|
|
745
745
|
}),
|
|
746
746
|
status: Field.select(
|
|
747
|
-
|
|
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
|
-
//
|
|
828
|
-
//
|
|
829
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
1357
|
-
*
|
|
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
|
-
|
|
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 (
|
|
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,6 +1558,676 @@ var ApprovalService = class _ApprovalService {
|
|
|
1415
1558
|
const fresh = await this.getRequest(requestId, context);
|
|
1416
1559
|
return { request: fresh, runId, resumed };
|
|
1417
1560
|
}
|
|
1561
|
+
// ── Send back for revision / resubmit (ADR-0044) ─────────────
|
|
1562
|
+
/**
|
|
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.
|
|
1574
|
+
*/
|
|
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`);
|
|
1581
|
+
}
|
|
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;
|
|
1597
|
+
}
|
|
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");
|
|
1631
|
+
}
|
|
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
|
+
});
|
|
1646
|
+
}
|
|
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 };
|
|
1663
|
+
}
|
|
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") {
|
|
1677
|
+
try {
|
|
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)
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
}
|
|
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
|
+
});
|
|
1703
|
+
}
|
|
1704
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1705
|
+
return { request: fresh, runId, resumed };
|
|
1706
|
+
}
|
|
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,
|
|
1733
|
+
context: SYSTEM_CTX
|
|
1734
|
+
});
|
|
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)
|
|
1768
|
+
});
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1772
|
+
return { request: fresh, runId, resumed };
|
|
1773
|
+
}
|
|
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;
|
|
1802
|
+
const rows = await this.engine.find("sys_approval_request", {
|
|
1803
|
+
where: { flow_run_id: runId },
|
|
1804
|
+
orderBy: [{ field: "created_at", order: "desc" }],
|
|
1805
|
+
limit: 1,
|
|
1806
|
+
context: SYSTEM_CTX
|
|
1807
|
+
});
|
|
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
|
+
}
|
|
1812
|
+
}
|
|
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
|
+
}
|
|
1418
2231
|
// ── Display enrichment ───────────────────────────────────────
|
|
1419
2232
|
/**
|
|
1420
2233
|
* Resolve the schema-declared display field for an object, when the engine
|
|
@@ -1612,37 +2425,208 @@ var ApprovalService = class _ApprovalService {
|
|
|
1612
2425
|
}
|
|
1613
2426
|
}
|
|
1614
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
|
+
}
|
|
1615
2527
|
// ── Read API ─────────────────────────────────────────────────
|
|
1616
|
-
|
|
2528
|
+
/** Filter type accepted by {@link listRequests} / {@link countRequests}. */
|
|
2529
|
+
buildRequestWhere(filter, context) {
|
|
1617
2530
|
const f = {};
|
|
1618
2531
|
if (filter?.object) f.object_name = filter.object;
|
|
1619
2532
|
if (filter?.recordId) f.record_id = filter.recordId;
|
|
1620
2533
|
if (filter?.submitterId) f.submitter_id = filter.submitterId;
|
|
1621
|
-
const tenantOrg = context?.organizationId ?? context?.tenantId;
|
|
2534
|
+
const tenantOrg = context?.organizationId ?? context?.tenantId ?? null;
|
|
1622
2535
|
if (tenantOrg) f.organization_id = tenantOrg;
|
|
1623
|
-
|
|
1624
|
-
if (
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
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,
|
|
1630
2571
|
context: SYSTEM_CTX
|
|
1631
2572
|
});
|
|
1632
|
-
|
|
1633
|
-
if (
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
const pending = r.pending_approvers ?? [];
|
|
1639
|
-
return targets.some((t) => pending.includes(t));
|
|
1640
|
-
});
|
|
1641
|
-
}
|
|
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
|
+
});
|
|
1642
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) : [];
|
|
1643
2603
|
await this.enrichRows(list);
|
|
1644
2604
|
return list;
|
|
1645
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
|
+
}
|
|
1646
2630
|
async getRequest(requestId, context) {
|
|
1647
2631
|
if (!requestId) return null;
|
|
1648
2632
|
const where = { id: requestId };
|
|
@@ -1656,8 +2640,44 @@ var ApprovalService = class _ApprovalService {
|
|
|
1656
2640
|
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
1657
2641
|
const row = rowFromRequest(rows[0]);
|
|
1658
2642
|
await this.enrichRows([row]);
|
|
2643
|
+
await this.attachFlowSteps(row);
|
|
1659
2644
|
return row;
|
|
1660
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
|
+
}
|
|
1661
2681
|
async listActions(requestId, context) {
|
|
1662
2682
|
if (!requestId) return [];
|
|
1663
2683
|
const req = await this.getRequest(requestId, context);
|
|
@@ -1665,7 +2685,7 @@ var ApprovalService = class _ApprovalService {
|
|
|
1665
2685
|
const rows = await this.engine.find("sys_approval_action", {
|
|
1666
2686
|
where: { request_id: requestId },
|
|
1667
2687
|
limit: 500,
|
|
1668
|
-
orderBy: [{ field: "created_at",
|
|
2688
|
+
orderBy: [{ field: "created_at", order: "asc" }],
|
|
1669
2689
|
context: SYSTEM_CTX
|
|
1670
2690
|
});
|
|
1671
2691
|
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
|
|
@@ -1679,6 +2699,153 @@ var ApprovalService = class _ApprovalService {
|
|
|
1679
2699
|
return actions;
|
|
1680
2700
|
}
|
|
1681
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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
|
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
|
+
}
|
|
1682
2849
|
|
|
1683
2850
|
// src/lifecycle-hooks.ts
|
|
1684
2851
|
var APPROVALS_HOOK_PACKAGE = "plugin-approvals:lock";
|
|
@@ -1817,6 +2984,7 @@ var ApprovalsServicePlugin = class {
|
|
|
1817
2984
|
this.version = "1.0.0";
|
|
1818
2985
|
this.type = "standard";
|
|
1819
2986
|
this.dependencies = ["com.objectstack.engine.objectql"];
|
|
2987
|
+
this.escalationJobScheduled = false;
|
|
1820
2988
|
this.options = options;
|
|
1821
2989
|
}
|
|
1822
2990
|
async init(ctx) {
|
|
@@ -1828,7 +2996,7 @@ var ApprovalsServicePlugin = class {
|
|
|
1828
2996
|
scope: "system",
|
|
1829
2997
|
defaultDatasource: "cloud",
|
|
1830
2998
|
namespace: "sys",
|
|
1831
|
-
objects: [SysApprovalRequest, SysApprovalAction],
|
|
2999
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
|
|
1832
3000
|
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
1833
3001
|
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
1834
3002
|
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
@@ -1878,7 +3046,8 @@ var ApprovalsServicePlugin = class {
|
|
|
1878
3046
|
this.engine = engine;
|
|
1879
3047
|
this.service = new ApprovalService({
|
|
1880
3048
|
engine,
|
|
1881
|
-
logger: ctx.logger
|
|
3049
|
+
logger: ctx.logger,
|
|
3050
|
+
publicBaseUrl: this.options.publicBaseUrl
|
|
1882
3051
|
});
|
|
1883
3052
|
if (!this.options.disableAutoHooks) {
|
|
1884
3053
|
try {
|
|
@@ -1890,6 +3059,86 @@ var ApprovalsServicePlugin = class {
|
|
|
1890
3059
|
}
|
|
1891
3060
|
ctx.registerService("approvals", this.service);
|
|
1892
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
|
+
}
|
|
1893
3142
|
try {
|
|
1894
3143
|
const automation = ctx.getService("automation");
|
|
1895
3144
|
if (automation && typeof automation.registerNodeExecutor === "function") {
|
|
@@ -1900,7 +3149,15 @@ var ApprovalsServicePlugin = class {
|
|
|
1900
3149
|
ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
|
|
1901
3150
|
}
|
|
1902
3151
|
}
|
|
1903
|
-
async stop(
|
|
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
|
+
}
|
|
1904
3161
|
if (this.engine) {
|
|
1905
3162
|
try {
|
|
1906
3163
|
unbindAllHooks(this.engine);
|
|
@@ -1913,6 +3170,7 @@ export {
|
|
|
1913
3170
|
ApprovalService,
|
|
1914
3171
|
ApprovalsServicePlugin,
|
|
1915
3172
|
SysApprovalAction,
|
|
3173
|
+
SysApprovalApprover,
|
|
1916
3174
|
SysApprovalRequest,
|
|
1917
3175
|
registerApprovalNode
|
|
1918
3176
|
};
|