@objectstack/plugin-approvals 9.0.0 → 9.1.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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @objectstack/plugin-approvals@9.0.0 build /home/runner/work/framework/framework/packages/plugins/plugin-approvals
2
+ > @objectstack/plugin-approvals@9.1.0 build /home/runner/work/framework/framework/packages/plugins/plugin-approvals
3
3
  > tsup --config ../../../tsup.config.ts
4
4
 
5
5
  CLI Building entry: src/index.ts
@@ -10,13 +10,13 @@
10
10
  CLI Cleaning output folder
11
11
  ESM Build start
12
12
  CJS Build start
13
- ESM dist/index.mjs 56.08 KB
14
- ESM dist/index.mjs.map 98.07 KB
15
- ESM ⚡️ Build success in 152ms
16
- CJS dist/index.js 57.41 KB
17
- CJS dist/index.js.map 99.25 KB
18
- CJS ⚡️ Build success in 159ms
13
+ ESM dist/index.mjs 63.58 KB
14
+ ESM dist/index.mjs.map 112.27 KB
15
+ ESM ⚡️ Build success in 137ms
16
+ CJS dist/index.js 64.93 KB
17
+ CJS dist/index.js.map 113.45 KB
18
+ CJS ⚡️ Build success in 138ms
19
19
  DTS Build start
20
- DTS ⚡️ Build success in 19242ms
21
- DTS dist/index.d.mts 332.92 KB
22
- DTS dist/index.d.ts 332.92 KB
20
+ DTS ⚡️ Build success in 25539ms
21
+ DTS dist/index.d.mts 334.31 KB
22
+ DTS dist/index.d.ts 334.31 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # @objectstack/plugin-approvals
2
2
 
3
+ ## 9.1.0
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [b9062c9]
8
+ - @objectstack/spec@9.1.0
9
+ - @objectstack/core@9.1.0
10
+ - @objectstack/formula@9.1.0
11
+ - @objectstack/metadata-core@9.1.0
12
+ - @objectstack/platform-objects@9.1.0
13
+
14
+ ## 9.0.1
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [1817845]
19
+ - @objectstack/spec@9.0.1
20
+ - @objectstack/core@9.0.1
21
+ - @objectstack/formula@9.0.1
22
+ - @objectstack/metadata-core@9.0.1
23
+ - @objectstack/platform-objects@9.0.1
24
+
3
25
  ## 9.0.0
4
26
 
5
27
  ### Patch Changes
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as _objectstack_spec_data from '@objectstack/spec/data';
2
2
  import { ApprovalNodeConfig } from '@objectstack/spec/automation';
3
- import { IApprovalService, SharingExecutionContext, ApprovalRequestRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalStatus, ApprovalActionRow } from '@objectstack/spec/contracts';
3
+ import { IApprovalService, SharingExecutionContext, ApprovalRequestRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalRecallInput, ApprovalRecallResult, ApprovalStatus, ApprovalActionRow } from '@objectstack/spec/contracts';
4
4
  export { ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalRequestRow, ApprovalStatus, IApprovalService } from '@objectstack/spec/contracts';
5
5
  import { Plugin, PluginContext } from '@objectstack/core';
6
6
 
@@ -7502,6 +7502,10 @@ declare class ApprovalService implements IApprovalService {
7502
7502
  nodeId: string;
7503
7503
  config: ApprovalNodeConfig;
7504
7504
  flowName?: string;
7505
+ /** Authored flow label, snapshotted for inbox display. */
7506
+ flowLabel?: string;
7507
+ /** Authored node label, snapshotted for inbox display. */
7508
+ nodeLabel?: string;
7505
7509
  submitterId?: string | null;
7506
7510
  record?: any;
7507
7511
  organizationId?: string | null;
@@ -7530,6 +7534,28 @@ declare class ApprovalService implements IApprovalService {
7530
7534
  * resumes the owning flow run down the matching `approve` / `reject` edge.
7531
7535
  */
7532
7536
  decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult>;
7537
+ /**
7538
+ * Withdraw a pending request (submitter only). Finalises the row as
7539
+ * `recalled`, releases the record lock (keyed on pending status), mirrors
7540
+ * the status field when configured, and resumes the owning flow run down
7541
+ * the `reject` branch with `output.decision = 'recall'` — the engine has no
7542
+ * run-cancel primitive, and leaving the run suspended forever would leak it.
7543
+ */
7544
+ recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise<ApprovalRecallResult>;
7545
+ /**
7546
+ * Resolve the schema-declared display field for an object, when the engine
7547
+ * exposes schema metadata (`getSchema`). Falls back to common title-ish
7548
+ * field names so plain `ApprovalEngine` fakes still enrich sensibly.
7549
+ */
7550
+ private resolveDisplayField;
7551
+ private static pickTitle;
7552
+ /**
7553
+ * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
7554
+ * Batched: one query per distinct target object plus one `sys_user` lookup.
7555
+ * Best-effort — a deleted record falls back to the payload snapshot, and a
7556
+ * lookup failure leaves the field unset rather than failing the list.
7557
+ */
7558
+ private enrichRows;
7533
7559
  listRequests(filter: {
7534
7560
  object?: string;
7535
7561
  recordId?: string;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as _objectstack_spec_data from '@objectstack/spec/data';
2
2
  import { ApprovalNodeConfig } from '@objectstack/spec/automation';
3
- import { IApprovalService, SharingExecutionContext, ApprovalRequestRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalStatus, ApprovalActionRow } from '@objectstack/spec/contracts';
3
+ import { IApprovalService, SharingExecutionContext, ApprovalRequestRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalRecallInput, ApprovalRecallResult, ApprovalStatus, ApprovalActionRow } from '@objectstack/spec/contracts';
4
4
  export { ApprovalActionRow, ApprovalDecisionInput, ApprovalDecisionResult, ApprovalRequestRow, ApprovalStatus, IApprovalService } from '@objectstack/spec/contracts';
5
5
  import { Plugin, PluginContext } from '@objectstack/core';
6
6
 
@@ -7502,6 +7502,10 @@ declare class ApprovalService implements IApprovalService {
7502
7502
  nodeId: string;
7503
7503
  config: ApprovalNodeConfig;
7504
7504
  flowName?: string;
7505
+ /** Authored flow label, snapshotted for inbox display. */
7506
+ flowLabel?: string;
7507
+ /** Authored node label, snapshotted for inbox display. */
7508
+ nodeLabel?: string;
7505
7509
  submitterId?: string | null;
7506
7510
  record?: any;
7507
7511
  organizationId?: string | null;
@@ -7530,6 +7534,28 @@ declare class ApprovalService implements IApprovalService {
7530
7534
  * resumes the owning flow run down the matching `approve` / `reject` edge.
7531
7535
  */
7532
7536
  decide(requestId: string, input: ApprovalDecisionInput, context: SharingExecutionContext): Promise<ApprovalDecisionResult>;
7537
+ /**
7538
+ * Withdraw a pending request (submitter only). Finalises the row as
7539
+ * `recalled`, releases the record lock (keyed on pending status), mirrors
7540
+ * the status field when configured, and resumes the owning flow run down
7541
+ * the `reject` branch with `output.decision = 'recall'` — the engine has no
7542
+ * run-cancel primitive, and leaving the run suspended forever would leak it.
7543
+ */
7544
+ recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise<ApprovalRecallResult>;
7545
+ /**
7546
+ * Resolve the schema-declared display field for an object, when the engine
7547
+ * exposes schema metadata (`getSchema`). Falls back to common title-ish
7548
+ * field names so plain `ApprovalEngine` fakes still enrich sensibly.
7549
+ */
7550
+ private resolveDisplayField;
7551
+ private static pickTitle;
7552
+ /**
7553
+ * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
7554
+ * Batched: one query per distinct target object plus one `sys_user` lookup.
7555
+ * Best-effort — a deleted record falls back to the payload snapshot, and a
7556
+ * lookup failure leaves the field unset rather than failing the list.
7557
+ */
7558
+ private enrichRows;
7533
7559
  listRequests(filter: {
7534
7560
  object?: string;
7535
7561
  recordId?: string;
package/dist/index.js CHANGED
@@ -976,7 +976,14 @@ function csvSplit(raw) {
976
976
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
977
977
  return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
978
978
  }
979
+ function prettifyMachineName(raw) {
980
+ if (!raw) return void 0;
981
+ const base = String(raw).replace(/^flow:/, "").trim();
982
+ if (!base) return void 0;
983
+ return base.split(/[_\-\s]+/).filter(Boolean).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
984
+ }
979
985
  function rowFromRequest(row) {
986
+ const cfg = parseJson(row.node_config_json, void 0);
980
987
  return {
981
988
  id: String(row.id),
982
989
  organization_id: row.organization_id ?? void 0,
@@ -994,7 +1001,11 @@ function rowFromRequest(row) {
994
1001
  flow_node_id: row.flow_node_id ?? void 0,
995
1002
  completed_at: row.completed_at ?? void 0,
996
1003
  created_at: row.created_at ?? void 0,
997
- updated_at: row.updated_at ?? void 0
1004
+ updated_at: row.updated_at ?? void 0,
1005
+ // The row is created at submission time; expose the stable inbox-facing name.
1006
+ submitted_at: row.created_at ?? void 0,
1007
+ process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
1008
+ step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step)
998
1009
  };
999
1010
  }
1000
1011
  function rowFromAction(row) {
@@ -1009,7 +1020,7 @@ function rowFromAction(row) {
1009
1020
  created_at: row.created_at ?? void 0
1010
1021
  };
1011
1022
  }
1012
- var ApprovalService = class {
1023
+ var ApprovalService = class _ApprovalService {
1013
1024
  constructor(opts) {
1014
1025
  this.engine = opts.engine;
1015
1026
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
@@ -1211,6 +1222,9 @@ var ApprovalService = class {
1211
1222
  const now = this.clock.now().toISOString();
1212
1223
  const id = uid("areq");
1213
1224
  const processName = `flow:${input.flowName ?? input.nodeId}`;
1225
+ const configSnapshot = { ...input.config };
1226
+ if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1227
+ if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1214
1228
  const row = {
1215
1229
  id,
1216
1230
  process_name: processName,
@@ -1224,7 +1238,7 @@ var ApprovalService = class {
1224
1238
  payload_json: input.record != null ? JSON.stringify(input.record) : null,
1225
1239
  flow_run_id: input.runId,
1226
1240
  flow_node_id: input.nodeId,
1227
- node_config_json: JSON.stringify(input.config),
1241
+ node_config_json: JSON.stringify(configSnapshot),
1228
1242
  organization_id: ctxOrg,
1229
1243
  created_at: now,
1230
1244
  updated_at: now
@@ -1356,6 +1370,172 @@ var ApprovalService = class {
1356
1370
  resumed
1357
1371
  };
1358
1372
  }
1373
+ /**
1374
+ * Withdraw a pending request (submitter only). Finalises the row as
1375
+ * `recalled`, releases the record lock (keyed on pending status), mirrors
1376
+ * the status field when configured, and resumes the owning flow run down
1377
+ * the `reject` branch with `output.decision = 'recall'` — the engine has no
1378
+ * run-cancel primitive, and leaving the run suspended forever would leak it.
1379
+ */
1380
+ async recall(requestId, input, context) {
1381
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
1382
+ if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
1383
+ const rawRows = await this.engine.find("sys_approval_request", {
1384
+ where: { id: requestId },
1385
+ limit: 1,
1386
+ context: SYSTEM_CTX
1387
+ });
1388
+ const raw = Array.isArray(rawRows) ? rawRows[0] : null;
1389
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1390
+ if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1391
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1392
+ throw new Error(`FORBIDDEN: only the submitter may recall this request`);
1393
+ }
1394
+ const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
1395
+ const org = raw.organization_id ?? null;
1396
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
1397
+ const runId = raw.flow_run_id ?? null;
1398
+ const now = this.clock.now().toISOString();
1399
+ await this.engine.insert("sys_approval_action", {
1400
+ id: uid("aact"),
1401
+ request_id: requestId,
1402
+ organization_id: org,
1403
+ step_name: nodeId,
1404
+ step_index: 0,
1405
+ action: "recall",
1406
+ actor_id: input.actorId,
1407
+ comment: input.comment ?? null,
1408
+ created_at: now
1409
+ }, { context: SYSTEM_CTX });
1410
+ await this.engine.update("sys_approval_request", {
1411
+ id: requestId,
1412
+ status: "recalled",
1413
+ pending_approvers: null,
1414
+ completed_at: now,
1415
+ updated_at: now
1416
+ }, { context: SYSTEM_CTX });
1417
+ if (config.approvalStatusField) {
1418
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
1419
+ }
1420
+ let resumed = false;
1421
+ if (runId && typeof this.automation?.resume === "function") {
1422
+ try {
1423
+ await this.automation.resume(runId, {
1424
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
1425
+ output: { decision: "recall", requestId }
1426
+ });
1427
+ resumed = true;
1428
+ } catch (err) {
1429
+ this.logger?.warn?.("[approvals] resume after recall failed", {
1430
+ request: requestId,
1431
+ run: runId,
1432
+ error: err?.message ?? String(err)
1433
+ });
1434
+ }
1435
+ }
1436
+ const fresh = await this.getRequest(requestId, context);
1437
+ return { request: fresh, runId, resumed };
1438
+ }
1439
+ // ── Display enrichment ───────────────────────────────────────
1440
+ /**
1441
+ * Resolve the schema-declared display field for an object, when the engine
1442
+ * exposes schema metadata (`getSchema`). Falls back to common title-ish
1443
+ * field names so plain `ApprovalEngine` fakes still enrich sensibly.
1444
+ */
1445
+ resolveDisplayField(object) {
1446
+ try {
1447
+ const schema = this.engine.getSchema?.(object);
1448
+ const fields = schema?.fields ?? {};
1449
+ const declared = schema?.displayNameField;
1450
+ if (declared && declared !== "id" && fields[declared]) return declared;
1451
+ for (const cand of ["name", "title", "subject", "label"]) {
1452
+ if (fields[cand]) return cand;
1453
+ }
1454
+ } catch {
1455
+ }
1456
+ return void 0;
1457
+ }
1458
+ static pickTitle(rec, displayField) {
1459
+ const candidates = displayField ? [displayField, "name", "title", "subject", "label"] : ["name", "title", "subject", "label"];
1460
+ for (const f of candidates) {
1461
+ const v = rec?.[f];
1462
+ if (v != null && String(v).trim() && f !== "id") return String(v);
1463
+ }
1464
+ return void 0;
1465
+ }
1466
+ /**
1467
+ * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
1468
+ * Batched: one query per distinct target object plus one `sys_user` lookup.
1469
+ * Best-effort — a deleted record falls back to the payload snapshot, and a
1470
+ * lookup failure leaves the field unset rather than failing the list.
1471
+ */
1472
+ async enrichRows(rows) {
1473
+ if (!rows.length) return;
1474
+ const byObject = /* @__PURE__ */ new Map();
1475
+ for (const r of rows) {
1476
+ if (!r.object_name || !r.record_id) continue;
1477
+ let set = byObject.get(r.object_name);
1478
+ if (!set) {
1479
+ set = /* @__PURE__ */ new Set();
1480
+ byObject.set(r.object_name, set);
1481
+ }
1482
+ set.add(r.record_id);
1483
+ }
1484
+ const titles = /* @__PURE__ */ new Map();
1485
+ for (const [object, idSet] of byObject) {
1486
+ const ids = Array.from(idSet);
1487
+ const displayField = this.resolveDisplayField(object);
1488
+ try {
1489
+ const recs = await this.engine.find(object, {
1490
+ where: { id: { $in: ids } },
1491
+ limit: ids.length,
1492
+ context: SYSTEM_CTX
1493
+ });
1494
+ for (const rec of recs ?? []) {
1495
+ const title = _ApprovalService.pickTitle(rec, displayField);
1496
+ if (rec?.id && title) titles.set(`${object}\0${rec.id}`, title);
1497
+ }
1498
+ } catch {
1499
+ }
1500
+ }
1501
+ const submitters = Array.from(new Set(rows.map((r) => r.submitter_id).filter(Boolean)));
1502
+ const names = /* @__PURE__ */ new Map();
1503
+ if (submitters.length) {
1504
+ try {
1505
+ const users = await this.engine.find("sys_user", {
1506
+ where: { id: { $in: submitters } },
1507
+ fields: ["id", "name", "email"],
1508
+ limit: submitters.length,
1509
+ context: SYSTEM_CTX
1510
+ });
1511
+ for (const u of users ?? []) {
1512
+ if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
1513
+ }
1514
+ } catch {
1515
+ }
1516
+ const unresolvedEmails = submitters.filter((s) => !names.has(s) && s.includes("@"));
1517
+ if (unresolvedEmails.length) {
1518
+ try {
1519
+ const users = await this.engine.find("sys_user", {
1520
+ where: { email: { $in: unresolvedEmails } },
1521
+ fields: ["email", "name"],
1522
+ limit: unresolvedEmails.length,
1523
+ context: SYSTEM_CTX
1524
+ });
1525
+ for (const u of users ?? []) {
1526
+ if (u?.email && u.name) names.set(String(u.email), String(u.name));
1527
+ }
1528
+ } catch {
1529
+ }
1530
+ }
1531
+ }
1532
+ for (const r of rows) {
1533
+ const title = titles.get(`${r.object_name}\0${r.record_id}`) ?? _ApprovalService.pickTitle(r.payload, void 0);
1534
+ if (title) r.record_title = title;
1535
+ const name = r.submitter_id ? names.get(String(r.submitter_id)) : void 0;
1536
+ if (name) r.submitter_name = name;
1537
+ }
1538
+ }
1359
1539
  // ── Read API ─────────────────────────────────────────────────
1360
1540
  async listRequests(filter, context) {
1361
1541
  const f = {};
@@ -1384,6 +1564,7 @@ var ApprovalService = class {
1384
1564
  });
1385
1565
  }
1386
1566
  }
1567
+ await this.enrichRows(list);
1387
1568
  return list;
1388
1569
  }
1389
1570
  async getRequest(requestId, context) {
@@ -1396,7 +1577,10 @@ var ApprovalService = class {
1396
1577
  limit: 1,
1397
1578
  context: SYSTEM_CTX
1398
1579
  });
1399
- return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
1580
+ if (!Array.isArray(rows) || !rows[0]) return null;
1581
+ const row = rowFromRequest(rows[0]);
1582
+ await this.enrichRows([row]);
1583
+ return row;
1400
1584
  }
1401
1585
  async listActions(requestId, context) {
1402
1586
  if (!requestId) return [];
@@ -1502,6 +1686,8 @@ function registerApprovalNode(automation, service, logger) {
1502
1686
  if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
1503
1687
  if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
1504
1688
  if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
1689
+ const flowName = variables.get("$flowName") ?? context?.flowName;
1690
+ const flowLabel = variables.get("$flowLabel");
1505
1691
  try {
1506
1692
  const request = await service.openNodeRequest({
1507
1693
  object,
@@ -1509,7 +1695,9 @@ function registerApprovalNode(automation, service, logger) {
1509
1695
  runId: String(runId),
1510
1696
  nodeId: node.id,
1511
1697
  config,
1512
- flowName: context?.flowName,
1698
+ flowName,
1699
+ flowLabel,
1700
+ nodeLabel: typeof node.label === "string" ? node.label : void 0,
1513
1701
  submitterId: context?.userId ?? null,
1514
1702
  record,
1515
1703
  organizationId: context?.organizationId ?? context?.tenantId ?? null