@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/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(input.config),
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
- return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
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: context?.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