@objectstack/plugin-approvals 8.0.1 → 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.
@@ -122,6 +122,20 @@ describe('Approval node bridge (ADR-0019)', () => {
122
122
  expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id });
123
123
  });
124
124
 
125
+ it('carries the flow name + authored labels onto the request row', async () => {
126
+ registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
127
+ await automation.execute('deal_approval', {
128
+ object: 'crm_deal', record: { id: 'd1', amount: 100 }, userId: 'submitter',
129
+ });
130
+ const [raw] = await fake.find('sys_approval_request', { where: { status: 'pending' } });
131
+ // Engine-seeded `$flowName` (not the node id) names the source…
132
+ expect(raw.process_name).toBe('flow:deal_approval');
133
+ // …and authored labels ride the config snapshot for inbox display.
134
+ const req = (await service.listRequests({ status: 'pending' }, { isSystem: true } as any))[0];
135
+ expect(req.process_label).toBe('Deal Approval');
136
+ expect(req.step_label).toBe('Manager Approval');
137
+ });
138
+
125
139
  it('resumes down the approve branch on approval', async () => {
126
140
  registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
127
141
  const paused = await automation.execute('deal_approval', {
@@ -97,6 +97,12 @@ export function registerApprovalNode(
97
97
  if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
98
98
  if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
99
99
 
100
+ // Flow identity comes from engine-seeded variables (`$flowName` /
101
+ // `$flowLabel`) so the request row can carry a human-readable origin;
102
+ // `context.flowName` is a legacy fallback for direct callers.
103
+ const flowName = (variables.get('$flowName') as string | undefined) ?? context?.flowName;
104
+ const flowLabel = variables.get('$flowLabel') as string | undefined;
105
+
100
106
  try {
101
107
  const request = await service.openNodeRequest({
102
108
  object,
@@ -104,7 +110,9 @@ export function registerApprovalNode(
104
110
  runId: String(runId),
105
111
  nodeId: node.id,
106
112
  config,
107
- flowName: context?.flowName,
113
+ flowName,
114
+ flowLabel,
115
+ nodeLabel: typeof node.label === 'string' ? node.label : undefined,
108
116
  submitterId: context?.userId ?? null,
109
117
  record,
110
118
  organizationId: context?.organizationId ?? context?.tenantId ?? null,
@@ -290,6 +290,98 @@ describe('ApprovalService (node era)', () => {
290
290
  it('getRequest: returns null for an unknown id', async () => {
291
291
  expect(await svc.getRequest('nope', SYS)).toBeNull();
292
292
  });
293
+
294
+ // ── recall ──────────────────────────────────────────────────────
295
+
296
+ it('recall: submitter withdraws a pending request', async () => {
297
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
298
+ const out = await svc.recall(req.id, { actorId: 'u1', comment: 'changed my mind' }, CTX);
299
+ expect(out.request.status).toBe('recalled');
300
+ expect(out.request.completed_at).toBeTruthy();
301
+ expect(out.request.pending_approvers).toEqual([]);
302
+ const actions = await svc.listActions(req.id, SYS);
303
+ expect(actions.map(a => a.action)).toEqual(['submit', 'recall']);
304
+ expect(actions[1].comment).toBe('changed my mind');
305
+ });
306
+
307
+ it('recall: blocks a non-submitter in a non-system context', async () => {
308
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
309
+ await expect(svc.recall(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any))
310
+ .rejects.toThrow(/FORBIDDEN/);
311
+ });
312
+
313
+ it('recall: rejects a recall on a non-pending request', async () => {
314
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
315
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
316
+ await expect(svc.recall(req.id, { actorId: 'u1' }, SYS)).rejects.toThrow(/INVALID_STATE/);
317
+ });
318
+
319
+ it('recall: resumes the owning run down the reject branch with decision=recall', async () => {
320
+ const resumed: any[] = [];
321
+ svc.attachAutomation({ async resume(runId, signal) { resumed.push({ runId, signal }); } });
322
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
323
+ const out = await svc.recall(req.id, { actorId: 'u1' }, CTX);
324
+ expect(out.resumed).toBe(true);
325
+ expect(resumed[0]).toMatchObject({
326
+ runId: 'run_1',
327
+ signal: { branchLabel: 'reject', output: { decision: 'recall' } },
328
+ });
329
+ });
330
+
331
+ it('recall: mirrors `recalled` onto the business record when configured', async () => {
332
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
333
+ const req = await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
334
+ await svc.recall(req.id, { actorId: 'u1' }, CTX);
335
+ expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
336
+ });
337
+
338
+ // ── inbox display fields ────────────────────────────────────────
339
+
340
+ it('rows expose submitted_at as an alias of created_at', async () => {
341
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
342
+ expect(req.submitted_at).toBeTruthy();
343
+ expect(req.submitted_at).toBe(req.created_at);
344
+ const listed = await svc.listRequests({ status: 'pending' }, SYS);
345
+ expect(listed[0].submitted_at).toBe(listed[0].created_at);
346
+ });
347
+
348
+ it('rows carry authored flow/node labels when provided', async () => {
349
+ const req = await svc.openNodeRequest(
350
+ openInput(['u9'], { flowLabel: 'Deal Approval', nodeLabel: 'Manager Review' }), CTX,
351
+ );
352
+ expect(req.process_label).toBe('Deal Approval');
353
+ expect(req.step_label).toBe('Manager Review');
354
+ });
355
+
356
+ it('rows fall back to prettified machine names when labels are absent', async () => {
357
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
358
+ expect(req.process_label).toBe('Deal Approval'); // from `flow:deal_approval`
359
+ expect(req.step_label).toBe('Approve Step'); // from `approve_step`
360
+ });
361
+
362
+ it('listRequests enriches record_title and submitter_name', async () => {
363
+ engine._tables['opportunity'] = [{ id: 'opp1', name: 'Acme Renewal', amount: 100 }];
364
+ engine._tables['sys_user'] = [{ id: 'u1', name: 'Ada Lovelace', email: 'ada@example.com' }];
365
+ await svc.openNodeRequest(openInput(['u9']), CTX); // submitter_id = u1 (CTX.userId)
366
+ const rows = await svc.listRequests({ status: 'pending' }, SYS);
367
+ expect(rows[0].record_title).toBe('Acme Renewal');
368
+ expect(rows[0].submitter_name).toBe('Ada Lovelace');
369
+ });
370
+
371
+ it('enrichment falls back to the payload snapshot when the record is gone', async () => {
372
+ await svc.openNodeRequest(
373
+ openInput(['u9'], { record: { id: 'opp1', name: 'Snapshot Title', amount: 1 } }), CTX,
374
+ );
375
+ const rows = await svc.listRequests({ status: 'pending' }, SYS);
376
+ expect(rows[0].record_title).toBe('Snapshot Title');
377
+ });
378
+
379
+ it('enrichment resolves an email submitter via sys_user.email', async () => {
380
+ engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
381
+ await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);
382
+ const rows = await svc.listRequests({ status: 'pending' }, SYS);
383
+ expect(rows[0].submitter_name).toBe('Grace Hopper');
384
+ });
293
385
  });
294
386
 
295
387
  describe('record-lock hook (node era)', () => {
@@ -10,6 +10,8 @@ import type {
10
10
  ApprovalActionRow,
11
11
  ApprovalDecisionInput,
12
12
  ApprovalDecisionResult,
13
+ ApprovalRecallInput,
14
+ ApprovalRecallResult,
13
15
  ApprovalStatus,
14
16
  SharingExecutionContext,
15
17
  } from '@objectstack/spec/contracts';
@@ -69,7 +71,27 @@ function csvSplit(raw: unknown): string[] {
69
71
  return String(raw).split(',').map(s => s.trim()).filter(Boolean);
70
72
  }
71
73
 
74
+ /**
75
+ * Humanize a machine name for display fallback: strips a `flow:` prefix and
76
+ * title-cases underscore/dash segments (`flow:manager_review` → "Manager
77
+ * Review"). Used only when no authored label was snapshotted on the row.
78
+ */
79
+ function prettifyMachineName(raw: string | null | undefined): string | undefined {
80
+ if (!raw) return undefined;
81
+ const base = String(raw).replace(/^flow:/, '').trim();
82
+ if (!base) return undefined;
83
+ return base
84
+ .split(/[_\-\s]+/)
85
+ .filter(Boolean)
86
+ .map(w => w.charAt(0).toUpperCase() + w.slice(1))
87
+ .join(' ');
88
+ }
89
+
72
90
  function rowFromRequest(row: any): ApprovalRequestRow {
91
+ // Authored display labels ride the node-config snapshot (`__flowLabel` /
92
+ // `__nodeLabel`) so they survive without a schema migration; fall back to a
93
+ // prettified machine name for rows written before labels were captured.
94
+ const cfg = parseJson<any>(row.node_config_json, undefined);
73
95
  return {
74
96
  id: String(row.id),
75
97
  organization_id: row.organization_id ?? undefined,
@@ -88,6 +110,10 @@ function rowFromRequest(row: any): ApprovalRequestRow {
88
110
  completed_at: row.completed_at ?? undefined,
89
111
  created_at: row.created_at ?? undefined,
90
112
  updated_at: row.updated_at ?? undefined,
113
+ // The row is created at submission time; expose the stable inbox-facing name.
114
+ submitted_at: row.created_at ?? undefined,
115
+ process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
116
+ step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
91
117
  } as any;
92
118
  }
93
119
 
@@ -291,6 +317,10 @@ export class ApprovalService implements IApprovalService {
291
317
  nodeId: string;
292
318
  config: ApprovalNodeConfig;
293
319
  flowName?: string;
320
+ /** Authored flow label, snapshotted for inbox display. */
321
+ flowLabel?: string;
322
+ /** Authored node label, snapshotted for inbox display. */
323
+ nodeLabel?: string;
294
324
  submitterId?: string | null;
295
325
  record?: any;
296
326
  organizationId?: string | null;
@@ -316,6 +346,11 @@ export class ApprovalService implements IApprovalService {
316
346
  const now = this.clock.now().toISOString();
317
347
  const id = uid('areq');
318
348
  const processName = `flow:${input.flowName ?? input.nodeId}`;
349
+ // Display labels ride the config snapshot (no schema migration needed);
350
+ // `rowFromRequest` surfaces them as `process_label` / `step_label`.
351
+ const configSnapshot: any = { ...input.config };
352
+ if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
353
+ if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
319
354
  const row: any = {
320
355
  id,
321
356
  process_name: processName,
@@ -329,7 +364,7 @@ export class ApprovalService implements IApprovalService {
329
364
  payload_json: input.record != null ? JSON.stringify(input.record) : null,
330
365
  flow_run_id: input.runId,
331
366
  flow_node_id: input.nodeId,
332
- node_config_json: JSON.stringify(input.config),
367
+ node_config_json: JSON.stringify(configSnapshot),
333
368
  organization_id: ctxOrg,
334
369
  created_at: now,
335
370
  updated_at: now,
@@ -463,6 +498,168 @@ export class ApprovalService implements IApprovalService {
463
498
  };
464
499
  }
465
500
 
501
+ /**
502
+ * Withdraw a pending request (submitter only). Finalises the row as
503
+ * `recalled`, releases the record lock (keyed on pending status), mirrors
504
+ * the status field when configured, and resumes the owning flow run down
505
+ * the `reject` branch with `output.decision = 'recall'` — the engine has no
506
+ * run-cancel primitive, and leaving the run suspended forever would leak it.
507
+ */
508
+ async recall(
509
+ requestId: string,
510
+ input: ApprovalRecallInput,
511
+ context: SharingExecutionContext,
512
+ ): Promise<ApprovalRecallResult> {
513
+ if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
514
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
515
+
516
+ const rawRows = await this.engine.find('sys_approval_request', {
517
+ where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
518
+ });
519
+ const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
520
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
521
+ if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
522
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
523
+ throw new Error(`FORBIDDEN: only the submitter may recall this request`);
524
+ }
525
+
526
+ const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
527
+ const org = raw.organization_id ?? null;
528
+ const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
529
+ const runId: string | null = raw.flow_run_id ?? null;
530
+ const now = this.clock.now().toISOString();
531
+
532
+ await this.engine.insert('sys_approval_action', {
533
+ id: uid('aact'), request_id: requestId, organization_id: org,
534
+ step_name: nodeId, step_index: 0, action: 'recall',
535
+ actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
536
+ }, { context: SYSTEM_CTX });
537
+
538
+ await this.engine.update('sys_approval_request', {
539
+ id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
540
+ }, { context: SYSTEM_CTX });
541
+ if (config.approvalStatusField) {
542
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'recalled');
543
+ }
544
+
545
+ let resumed = false;
546
+ if (runId && typeof this.automation?.resume === 'function') {
547
+ try {
548
+ await this.automation.resume(runId, {
549
+ branchLabel: APPROVAL_BRANCH_LABELS.reject,
550
+ output: { decision: 'recall', requestId },
551
+ });
552
+ resumed = true;
553
+ } catch (err: any) {
554
+ this.logger?.warn?.('[approvals] resume after recall failed', {
555
+ request: requestId, run: runId, error: err?.message ?? String(err),
556
+ });
557
+ }
558
+ }
559
+
560
+ const fresh = await this.getRequest(requestId, context);
561
+ return { request: fresh!, runId, resumed };
562
+ }
563
+
564
+ // ── Display enrichment ───────────────────────────────────────
565
+
566
+ /**
567
+ * Resolve the schema-declared display field for an object, when the engine
568
+ * exposes schema metadata (`getSchema`). Falls back to common title-ish
569
+ * field names so plain `ApprovalEngine` fakes still enrich sensibly.
570
+ */
571
+ private resolveDisplayField(object: string): string | undefined {
572
+ try {
573
+ const schema: any = (this.engine as any).getSchema?.(object);
574
+ const fields = schema?.fields ?? {};
575
+ const declared = schema?.displayNameField;
576
+ if (declared && declared !== 'id' && fields[declared]) return declared;
577
+ for (const cand of ['name', 'title', 'subject', 'label']) {
578
+ if (fields[cand]) return cand;
579
+ }
580
+ } catch { /* schema unavailable — heuristics below still apply */ }
581
+ return undefined;
582
+ }
583
+
584
+ private static pickTitle(rec: any, displayField?: string): string | undefined {
585
+ const candidates = displayField
586
+ ? [displayField, 'name', 'title', 'subject', 'label']
587
+ : ['name', 'title', 'subject', 'label'];
588
+ for (const f of candidates) {
589
+ const v = rec?.[f];
590
+ if (v != null && String(v).trim() && f !== 'id') return String(v);
591
+ }
592
+ return undefined;
593
+ }
594
+
595
+ /**
596
+ * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
597
+ * Batched: one query per distinct target object plus one `sys_user` lookup.
598
+ * Best-effort — a deleted record falls back to the payload snapshot, and a
599
+ * lookup failure leaves the field unset rather than failing the list.
600
+ */
601
+ private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
602
+ if (!rows.length) return;
603
+
604
+ // Record titles, batched per object.
605
+ const byObject = new Map<string, Set<string>>();
606
+ for (const r of rows) {
607
+ if (!r.object_name || !r.record_id) continue;
608
+ let set = byObject.get(r.object_name);
609
+ if (!set) { set = new Set(); byObject.set(r.object_name, set); }
610
+ set.add(r.record_id);
611
+ }
612
+ const titles = new Map<string, string>();
613
+ for (const [object, idSet] of byObject) {
614
+ const ids = Array.from(idSet);
615
+ const displayField = this.resolveDisplayField(object);
616
+ try {
617
+ const recs = await this.engine.find(object, {
618
+ where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
619
+ });
620
+ for (const rec of (recs ?? []) as any[]) {
621
+ const title = ApprovalService.pickTitle(rec, displayField);
622
+ if (rec?.id && title) titles.set(`${object}${rec.id}`, title);
623
+ }
624
+ } catch { /* object may be unregistered — payload fallback below */ }
625
+ }
626
+
627
+ // Submitter display names — submitter_id may be a user id or an email.
628
+ const submitters = Array.from(new Set(rows.map(r => r.submitter_id).filter(Boolean))) as string[];
629
+ const names = new Map<string, string>();
630
+ if (submitters.length) {
631
+ try {
632
+ const users = await this.engine.find('sys_user', {
633
+ where: { id: { $in: submitters } }, fields: ['id', 'name', 'email'],
634
+ limit: submitters.length, context: SYSTEM_CTX,
635
+ });
636
+ for (const u of (users ?? []) as any[]) {
637
+ if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
638
+ }
639
+ } catch { /* best-effort */ }
640
+ const unresolvedEmails = submitters.filter(s => !names.has(s) && s.includes('@'));
641
+ if (unresolvedEmails.length) {
642
+ try {
643
+ const users = await this.engine.find('sys_user', {
644
+ where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
645
+ limit: unresolvedEmails.length, context: SYSTEM_CTX,
646
+ });
647
+ for (const u of (users ?? []) as any[]) {
648
+ if (u?.email && u.name) names.set(String(u.email), String(u.name));
649
+ }
650
+ } catch { /* best-effort */ }
651
+ }
652
+ }
653
+
654
+ for (const r of rows as any[]) {
655
+ const title = titles.get(`${r.object_name}${r.record_id}`)
656
+ ?? ApprovalService.pickTitle(r.payload, undefined);
657
+ if (title) r.record_title = title;
658
+ const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
659
+ if (name) r.submitter_name = name;
660
+ }
661
+ }
662
+
466
663
  // ── Read API ─────────────────────────────────────────────────
467
664
 
468
665
  async listRequests(
@@ -513,6 +710,7 @@ export class ApprovalService implements IApprovalService {
513
710
  });
514
711
  }
515
712
  }
713
+ await this.enrichRows(list);
516
714
  return list;
517
715
  }
518
716
 
@@ -524,7 +722,10 @@ export class ApprovalService implements IApprovalService {
524
722
  const rows = await this.engine.find('sys_approval_request', {
525
723
  where, limit: 1, context: SYSTEM_CTX,
526
724
  });
527
- return Array.isArray(rows) && rows[0] ? rowFromRequest(rows[0]) : null;
725
+ if (!Array.isArray(rows) || !rows[0]) return null;
726
+ const row = rowFromRequest(rows[0]);
727
+ await this.enrichRows([row]);
728
+ return row;
528
729
  }
529
730
 
530
731
  async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {