@objectstack/plugin-approvals 9.0.0 → 9.0.1
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 +11 -0
- package/dist/index.d.mts +27 -1
- package/dist/index.d.ts +27 -1
- package/dist/index.js +193 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +193 -5
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/approval-node.test.ts +14 -0
- package/src/approval-node.ts +9 -1
- package/src/approval-service.test.ts +92 -0
- package/src/approval-service.ts +203 -2
package/dist/index.mjs
CHANGED
|
@@ -955,7 +955,14 @@ function csvSplit(raw) {
|
|
|
955
955
|
if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
|
|
956
956
|
return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
|
|
957
957
|
}
|
|
958
|
+
function prettifyMachineName(raw) {
|
|
959
|
+
if (!raw) return void 0;
|
|
960
|
+
const base = String(raw).replace(/^flow:/, "").trim();
|
|
961
|
+
if (!base) return void 0;
|
|
962
|
+
return base.split(/[_\-\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
963
|
+
}
|
|
958
964
|
function rowFromRequest(row) {
|
|
965
|
+
const cfg = parseJson(row.node_config_json, void 0);
|
|
959
966
|
return {
|
|
960
967
|
id: String(row.id),
|
|
961
968
|
organization_id: row.organization_id ?? void 0,
|
|
@@ -973,7 +980,11 @@ function rowFromRequest(row) {
|
|
|
973
980
|
flow_node_id: row.flow_node_id ?? void 0,
|
|
974
981
|
completed_at: row.completed_at ?? void 0,
|
|
975
982
|
created_at: row.created_at ?? void 0,
|
|
976
|
-
updated_at: row.updated_at ?? void 0
|
|
983
|
+
updated_at: row.updated_at ?? void 0,
|
|
984
|
+
// The row is created at submission time; expose the stable inbox-facing name.
|
|
985
|
+
submitted_at: row.created_at ?? void 0,
|
|
986
|
+
process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
|
|
987
|
+
step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step)
|
|
977
988
|
};
|
|
978
989
|
}
|
|
979
990
|
function rowFromAction(row) {
|
|
@@ -988,7 +999,7 @@ function rowFromAction(row) {
|
|
|
988
999
|
created_at: row.created_at ?? void 0
|
|
989
1000
|
};
|
|
990
1001
|
}
|
|
991
|
-
var ApprovalService = class {
|
|
1002
|
+
var ApprovalService = class _ApprovalService {
|
|
992
1003
|
constructor(opts) {
|
|
993
1004
|
this.engine = opts.engine;
|
|
994
1005
|
this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
|
|
@@ -1190,6 +1201,9 @@ var ApprovalService = class {
|
|
|
1190
1201
|
const now = this.clock.now().toISOString();
|
|
1191
1202
|
const id = uid("areq");
|
|
1192
1203
|
const processName = `flow:${input.flowName ?? input.nodeId}`;
|
|
1204
|
+
const configSnapshot = { ...input.config };
|
|
1205
|
+
if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
|
|
1206
|
+
if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
|
|
1193
1207
|
const row = {
|
|
1194
1208
|
id,
|
|
1195
1209
|
process_name: processName,
|
|
@@ -1203,7 +1217,7 @@ var ApprovalService = class {
|
|
|
1203
1217
|
payload_json: input.record != null ? JSON.stringify(input.record) : null,
|
|
1204
1218
|
flow_run_id: input.runId,
|
|
1205
1219
|
flow_node_id: input.nodeId,
|
|
1206
|
-
node_config_json: JSON.stringify(
|
|
1220
|
+
node_config_json: JSON.stringify(configSnapshot),
|
|
1207
1221
|
organization_id: ctxOrg,
|
|
1208
1222
|
created_at: now,
|
|
1209
1223
|
updated_at: now
|
|
@@ -1335,6 +1349,172 @@ var ApprovalService = class {
|
|
|
1335
1349
|
resumed
|
|
1336
1350
|
};
|
|
1337
1351
|
}
|
|
1352
|
+
/**
|
|
1353
|
+
* Withdraw a pending request (submitter only). Finalises the row as
|
|
1354
|
+
* `recalled`, releases the record lock (keyed on pending status), mirrors
|
|
1355
|
+
* the status field when configured, and resumes the owning flow run down
|
|
1356
|
+
* the `reject` branch with `output.decision = 'recall'` — the engine has no
|
|
1357
|
+
* run-cancel primitive, and leaving the run suspended forever would leak it.
|
|
1358
|
+
*/
|
|
1359
|
+
async recall(requestId, input, context) {
|
|
1360
|
+
if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
|
|
1361
|
+
if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
|
|
1362
|
+
const rawRows = await this.engine.find("sys_approval_request", {
|
|
1363
|
+
where: { id: requestId },
|
|
1364
|
+
limit: 1,
|
|
1365
|
+
context: SYSTEM_CTX
|
|
1366
|
+
});
|
|
1367
|
+
const raw = Array.isArray(rawRows) ? rawRows[0] : null;
|
|
1368
|
+
if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
|
|
1369
|
+
if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
|
|
1370
|
+
if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
|
|
1371
|
+
throw new Error(`FORBIDDEN: only the submitter may recall this request`);
|
|
1372
|
+
}
|
|
1373
|
+
const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
|
|
1374
|
+
const org = raw.organization_id ?? null;
|
|
1375
|
+
const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
|
|
1376
|
+
const runId = raw.flow_run_id ?? null;
|
|
1377
|
+
const now = this.clock.now().toISOString();
|
|
1378
|
+
await this.engine.insert("sys_approval_action", {
|
|
1379
|
+
id: uid("aact"),
|
|
1380
|
+
request_id: requestId,
|
|
1381
|
+
organization_id: org,
|
|
1382
|
+
step_name: nodeId,
|
|
1383
|
+
step_index: 0,
|
|
1384
|
+
action: "recall",
|
|
1385
|
+
actor_id: input.actorId,
|
|
1386
|
+
comment: input.comment ?? null,
|
|
1387
|
+
created_at: now
|
|
1388
|
+
}, { context: SYSTEM_CTX });
|
|
1389
|
+
await this.engine.update("sys_approval_request", {
|
|
1390
|
+
id: requestId,
|
|
1391
|
+
status: "recalled",
|
|
1392
|
+
pending_approvers: null,
|
|
1393
|
+
completed_at: now,
|
|
1394
|
+
updated_at: now
|
|
1395
|
+
}, { context: SYSTEM_CTX });
|
|
1396
|
+
if (config.approvalStatusField) {
|
|
1397
|
+
await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
|
|
1398
|
+
}
|
|
1399
|
+
let resumed = false;
|
|
1400
|
+
if (runId && typeof this.automation?.resume === "function") {
|
|
1401
|
+
try {
|
|
1402
|
+
await this.automation.resume(runId, {
|
|
1403
|
+
branchLabel: APPROVAL_BRANCH_LABELS.reject,
|
|
1404
|
+
output: { decision: "recall", requestId }
|
|
1405
|
+
});
|
|
1406
|
+
resumed = true;
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
this.logger?.warn?.("[approvals] resume after recall failed", {
|
|
1409
|
+
request: requestId,
|
|
1410
|
+
run: runId,
|
|
1411
|
+
error: err?.message ?? String(err)
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
const fresh = await this.getRequest(requestId, context);
|
|
1416
|
+
return { request: fresh, runId, resumed };
|
|
1417
|
+
}
|
|
1418
|
+
// ── Display enrichment ───────────────────────────────────────
|
|
1419
|
+
/**
|
|
1420
|
+
* Resolve the schema-declared display field for an object, when the engine
|
|
1421
|
+
* exposes schema metadata (`getSchema`). Falls back to common title-ish
|
|
1422
|
+
* field names so plain `ApprovalEngine` fakes still enrich sensibly.
|
|
1423
|
+
*/
|
|
1424
|
+
resolveDisplayField(object) {
|
|
1425
|
+
try {
|
|
1426
|
+
const schema = this.engine.getSchema?.(object);
|
|
1427
|
+
const fields = schema?.fields ?? {};
|
|
1428
|
+
const declared = schema?.displayNameField;
|
|
1429
|
+
if (declared && declared !== "id" && fields[declared]) return declared;
|
|
1430
|
+
for (const cand of ["name", "title", "subject", "label"]) {
|
|
1431
|
+
if (fields[cand]) return cand;
|
|
1432
|
+
}
|
|
1433
|
+
} catch {
|
|
1434
|
+
}
|
|
1435
|
+
return void 0;
|
|
1436
|
+
}
|
|
1437
|
+
static pickTitle(rec, displayField) {
|
|
1438
|
+
const candidates = displayField ? [displayField, "name", "title", "subject", "label"] : ["name", "title", "subject", "label"];
|
|
1439
|
+
for (const f of candidates) {
|
|
1440
|
+
const v = rec?.[f];
|
|
1441
|
+
if (v != null && String(v).trim() && f !== "id") return String(v);
|
|
1442
|
+
}
|
|
1443
|
+
return void 0;
|
|
1444
|
+
}
|
|
1445
|
+
/**
|
|
1446
|
+
* Attach inbox display fields (`record_title`, `submitter_name`) to rows.
|
|
1447
|
+
* Batched: one query per distinct target object plus one `sys_user` lookup.
|
|
1448
|
+
* Best-effort — a deleted record falls back to the payload snapshot, and a
|
|
1449
|
+
* lookup failure leaves the field unset rather than failing the list.
|
|
1450
|
+
*/
|
|
1451
|
+
async enrichRows(rows) {
|
|
1452
|
+
if (!rows.length) return;
|
|
1453
|
+
const byObject = /* @__PURE__ */ new Map();
|
|
1454
|
+
for (const r of rows) {
|
|
1455
|
+
if (!r.object_name || !r.record_id) continue;
|
|
1456
|
+
let set = byObject.get(r.object_name);
|
|
1457
|
+
if (!set) {
|
|
1458
|
+
set = /* @__PURE__ */ new Set();
|
|
1459
|
+
byObject.set(r.object_name, set);
|
|
1460
|
+
}
|
|
1461
|
+
set.add(r.record_id);
|
|
1462
|
+
}
|
|
1463
|
+
const titles = /* @__PURE__ */ new Map();
|
|
1464
|
+
for (const [object, idSet] of byObject) {
|
|
1465
|
+
const ids = Array.from(idSet);
|
|
1466
|
+
const displayField = this.resolveDisplayField(object);
|
|
1467
|
+
try {
|
|
1468
|
+
const recs = await this.engine.find(object, {
|
|
1469
|
+
where: { id: { $in: ids } },
|
|
1470
|
+
limit: ids.length,
|
|
1471
|
+
context: SYSTEM_CTX
|
|
1472
|
+
});
|
|
1473
|
+
for (const rec of recs ?? []) {
|
|
1474
|
+
const title = _ApprovalService.pickTitle(rec, displayField);
|
|
1475
|
+
if (rec?.id && title) titles.set(`${object}\0${rec.id}`, title);
|
|
1476
|
+
}
|
|
1477
|
+
} catch {
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
const submitters = Array.from(new Set(rows.map((r) => r.submitter_id).filter(Boolean)));
|
|
1481
|
+
const names = /* @__PURE__ */ new Map();
|
|
1482
|
+
if (submitters.length) {
|
|
1483
|
+
try {
|
|
1484
|
+
const users = await this.engine.find("sys_user", {
|
|
1485
|
+
where: { id: { $in: submitters } },
|
|
1486
|
+
fields: ["id", "name", "email"],
|
|
1487
|
+
limit: submitters.length,
|
|
1488
|
+
context: SYSTEM_CTX
|
|
1489
|
+
});
|
|
1490
|
+
for (const u of users ?? []) {
|
|
1491
|
+
if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
|
|
1492
|
+
}
|
|
1493
|
+
} catch {
|
|
1494
|
+
}
|
|
1495
|
+
const unresolvedEmails = submitters.filter((s) => !names.has(s) && s.includes("@"));
|
|
1496
|
+
if (unresolvedEmails.length) {
|
|
1497
|
+
try {
|
|
1498
|
+
const users = await this.engine.find("sys_user", {
|
|
1499
|
+
where: { email: { $in: unresolvedEmails } },
|
|
1500
|
+
fields: ["email", "name"],
|
|
1501
|
+
limit: unresolvedEmails.length,
|
|
1502
|
+
context: SYSTEM_CTX
|
|
1503
|
+
});
|
|
1504
|
+
for (const u of users ?? []) {
|
|
1505
|
+
if (u?.email && u.name) names.set(String(u.email), String(u.name));
|
|
1506
|
+
}
|
|
1507
|
+
} catch {
|
|
1508
|
+
}
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
for (const r of rows) {
|
|
1512
|
+
const title = titles.get(`${r.object_name}\0${r.record_id}`) ?? _ApprovalService.pickTitle(r.payload, void 0);
|
|
1513
|
+
if (title) r.record_title = title;
|
|
1514
|
+
const name = r.submitter_id ? names.get(String(r.submitter_id)) : void 0;
|
|
1515
|
+
if (name) r.submitter_name = name;
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1338
1518
|
// ── Read API ─────────────────────────────────────────────────
|
|
1339
1519
|
async listRequests(filter, context) {
|
|
1340
1520
|
const f = {};
|
|
@@ -1363,6 +1543,7 @@ var ApprovalService = class {
|
|
|
1363
1543
|
});
|
|
1364
1544
|
}
|
|
1365
1545
|
}
|
|
1546
|
+
await this.enrichRows(list);
|
|
1366
1547
|
return list;
|
|
1367
1548
|
}
|
|
1368
1549
|
async getRequest(requestId, context) {
|
|
@@ -1375,7 +1556,10 @@ var ApprovalService = class {
|
|
|
1375
1556
|
limit: 1,
|
|
1376
1557
|
context: SYSTEM_CTX
|
|
1377
1558
|
});
|
|
1378
|
-
|
|
1559
|
+
if (!Array.isArray(rows) || !rows[0]) return null;
|
|
1560
|
+
const row = rowFromRequest(rows[0]);
|
|
1561
|
+
await this.enrichRows([row]);
|
|
1562
|
+
return row;
|
|
1379
1563
|
}
|
|
1380
1564
|
async listActions(requestId, context) {
|
|
1381
1565
|
if (!requestId) return [];
|
|
@@ -1486,6 +1670,8 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
1486
1670
|
if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
|
|
1487
1671
|
if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
|
|
1488
1672
|
if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
|
|
1673
|
+
const flowName = variables.get("$flowName") ?? context?.flowName;
|
|
1674
|
+
const flowLabel = variables.get("$flowLabel");
|
|
1489
1675
|
try {
|
|
1490
1676
|
const request = await service.openNodeRequest({
|
|
1491
1677
|
object,
|
|
@@ -1493,7 +1679,9 @@ function registerApprovalNode(automation, service, logger) {
|
|
|
1493
1679
|
runId: String(runId),
|
|
1494
1680
|
nodeId: node.id,
|
|
1495
1681
|
config,
|
|
1496
|
-
flowName
|
|
1682
|
+
flowName,
|
|
1683
|
+
flowLabel,
|
|
1684
|
+
nodeLabel: typeof node.label === "string" ? node.label : void 0,
|
|
1497
1685
|
submitterId: context?.userId ?? null,
|
|
1498
1686
|
record,
|
|
1499
1687
|
organizationId: context?.organizationId ?? context?.tenantId ?? null
|