@objectstack/plugin-approvals 7.3.0 → 7.4.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 +85 -0
- package/dist/index.d.mts +6431 -107
- package/dist/index.d.ts +6431 -107
- package/dist/index.js +1237 -776
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1244 -779
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -7
- package/scripts/i18n-extract.config.ts +32 -0
- package/src/approval-node.test.ts +182 -0
- package/src/approval-node.ts +131 -0
- package/src/approval-service.test.ts +205 -304
- package/src/approval-service.ts +208 -491
- package/src/approvals-plugin.ts +61 -53
- package/src/index.ts +12 -11
- package/src/lifecycle-hooks.ts +67 -202
- package/src/nav-contribution.test.ts +46 -0
- package/src/sys-approval-action.object.ts +120 -0
- package/src/sys-approval-request.object.ts +227 -0
- package/src/translations/en.objects.generated.ts +156 -0
- package/src/translations/es-ES.objects.generated.ts +156 -0
- package/src/translations/index.ts +23 -0
- package/src/translations/ja-JP.objects.generated.ts +156 -0
- package/src/translations/zh-CN.objects.generated.ts +156 -0
- package/src/action-executor.ts +0 -313
- package/src/phase-b.test.ts +0 -263
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
4
|
+
import { AutomationEngine } from '@objectstack/service-automation';
|
|
5
|
+
import { ApprovalService } from './approval-service.js';
|
|
6
|
+
import { registerApprovalNode } from './approval-node.js';
|
|
7
|
+
|
|
8
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as any;
|
|
9
|
+
|
|
10
|
+
const noopLogger = {
|
|
11
|
+
info() {}, warn() {}, error() {}, debug() {},
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tiny in-memory ObjectQL stand-in — supports the `where`-equality + `$in`
|
|
16
|
+
* queries the approval service issues, enough to drive the node bridge.
|
|
17
|
+
*/
|
|
18
|
+
function makeFakeEngine() {
|
|
19
|
+
const tables = new Map<string, any[]>();
|
|
20
|
+
const rows = (o: string) => (tables.get(o) ?? (tables.set(o, []), tables.get(o)!));
|
|
21
|
+
const matches = (row: any, where: any) => Object.entries(where ?? {}).every(([k, v]) => {
|
|
22
|
+
if (v && typeof v === 'object' && '$in' in (v as any)) return (v as any).$in.includes(row[k]);
|
|
23
|
+
if (v && typeof v === 'object' && '$ne' in (v as any)) return row[k] !== (v as any).$ne;
|
|
24
|
+
return row[k] === v;
|
|
25
|
+
});
|
|
26
|
+
return {
|
|
27
|
+
tables,
|
|
28
|
+
async find(object: string, opts: any = {}) {
|
|
29
|
+
const where = opts.where ?? opts.filter ?? {};
|
|
30
|
+
let out = rows(object).filter(r => matches(r, where));
|
|
31
|
+
if (opts.limit) out = out.slice(0, opts.limit);
|
|
32
|
+
return out.map(r => ({ ...r }));
|
|
33
|
+
},
|
|
34
|
+
async insert(object: string, data: any) {
|
|
35
|
+
rows(object).push({ ...data });
|
|
36
|
+
return { ...data };
|
|
37
|
+
},
|
|
38
|
+
async update(object: string, idOrData: any) {
|
|
39
|
+
const id = idOrData.id;
|
|
40
|
+
const row = rows(object).find(r => r.id === id);
|
|
41
|
+
if (row) Object.assign(row, idOrData);
|
|
42
|
+
return row ? { ...row } : null;
|
|
43
|
+
},
|
|
44
|
+
async delete(object: string, opts: any = {}) {
|
|
45
|
+
const where = opts.where ?? {};
|
|
46
|
+
const list = rows(object);
|
|
47
|
+
for (let i = list.length - 1; i >= 0; i--) if (matches(list[i], where)) list.splice(i, 1);
|
|
48
|
+
return { affected: 1 };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function registerDecisionFlow(engine: AutomationEngine, approvers: Array<{ type: string; value?: string }>, behavior?: 'first_response' | 'unanimous') {
|
|
54
|
+
engine.registerFlow('deal_approval', {
|
|
55
|
+
name: 'deal_approval',
|
|
56
|
+
label: 'Deal Approval',
|
|
57
|
+
type: 'autolaunched',
|
|
58
|
+
nodes: [
|
|
59
|
+
{ id: 'start', type: 'start', label: 'Start' },
|
|
60
|
+
{ id: 'approve_step', type: 'approval', label: 'Manager Approval', config: { approvers, behavior } },
|
|
61
|
+
{ id: 'on_approved', type: 'mark', label: 'Approved' },
|
|
62
|
+
{ id: 'on_rejected', type: 'mark', label: 'Rejected' },
|
|
63
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
64
|
+
],
|
|
65
|
+
edges: [
|
|
66
|
+
{ id: 'e1', source: 'start', target: 'approve_step' },
|
|
67
|
+
{ id: 'e2', source: 'approve_step', target: 'on_approved', label: 'approve' },
|
|
68
|
+
{ id: 'e3', source: 'approve_step', target: 'on_rejected', label: 'reject' },
|
|
69
|
+
{ id: 'e4', source: 'on_approved', target: 'end' },
|
|
70
|
+
{ id: 'e5', source: 'on_rejected', target: 'end' },
|
|
71
|
+
],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
describe('Approval node bridge (ADR-0019)', () => {
|
|
76
|
+
let automation: AutomationEngine;
|
|
77
|
+
let service: ApprovalService;
|
|
78
|
+
let fake: ReturnType<typeof makeFakeEngine>;
|
|
79
|
+
const marks: string[] = [];
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
marks.length = 0;
|
|
83
|
+
automation = new AutomationEngine(noopLogger as any);
|
|
84
|
+
fake = makeFakeEngine();
|
|
85
|
+
service = new ApprovalService({ engine: fake as any, logger: noopLogger });
|
|
86
|
+
// The contract `decide()` resumes via the attached automation surface.
|
|
87
|
+
service.attachAutomation(automation);
|
|
88
|
+
registerApprovalNode(automation, service, noopLogger);
|
|
89
|
+
// A terminal "mark" node records which branch ran.
|
|
90
|
+
automation.registerNodeExecutor({
|
|
91
|
+
type: 'mark',
|
|
92
|
+
async execute(node: any) { marks.push(node.id); return { success: true }; },
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('publishes an approval action descriptor that supports pause', () => {
|
|
97
|
+
const descriptors = automation.getActionDescriptors();
|
|
98
|
+
const approval = descriptors.find(d => d.type === 'approval');
|
|
99
|
+
expect(approval).toBeDefined();
|
|
100
|
+
expect(approval!.supportsPause).toBe(true);
|
|
101
|
+
expect(approval!.category).toBe('human');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('suspends the run on entry and opens a pending request', async () => {
|
|
105
|
+
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
106
|
+
const result = await automation.execute('deal_approval', {
|
|
107
|
+
object: 'crm_deal',
|
|
108
|
+
record: { id: 'd1', amount: 100 },
|
|
109
|
+
userId: 'submitter',
|
|
110
|
+
});
|
|
111
|
+
expect(result.status).toBe('paused');
|
|
112
|
+
expect(result.runId).toBeDefined();
|
|
113
|
+
expect(marks).toHaveLength(0);
|
|
114
|
+
|
|
115
|
+
const requests = await fake.find('sys_approval_request', { where: { status: 'pending' } });
|
|
116
|
+
expect(requests).toHaveLength(1);
|
|
117
|
+
expect(requests[0]).toMatchObject({
|
|
118
|
+
object_name: 'crm_deal', record_id: 'd1', flow_run_id: result.runId, flow_node_id: 'approve_step',
|
|
119
|
+
});
|
|
120
|
+
// Surfaced as a suspended run with the request id as correlation.
|
|
121
|
+
const suspended = automation.listSuspendedRuns();
|
|
122
|
+
expect(suspended[0]).toMatchObject({ nodeId: 'approve_step', correlation: requests[0].id });
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('resumes down the approve branch on approval', async () => {
|
|
126
|
+
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
127
|
+
const paused = await automation.execute('deal_approval', {
|
|
128
|
+
object: 'crm_deal', record: { id: 'd1' }, userId: 'submitter',
|
|
129
|
+
});
|
|
130
|
+
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
131
|
+
|
|
132
|
+
const out = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
133
|
+
|
|
134
|
+
expect(out).toMatchObject({ finalized: true, decision: 'approve', resumed: true });
|
|
135
|
+
expect(marks).toEqual(['on_approved']);
|
|
136
|
+
expect(automation.listSuspendedRuns()).toHaveLength(0);
|
|
137
|
+
|
|
138
|
+
const finalReq = (await fake.find('sys_approval_request', { where: { id: request.id } }))[0];
|
|
139
|
+
expect(finalReq.status).toBe('approved');
|
|
140
|
+
expect(paused.runId).toBeDefined();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('resumes down the reject branch on rejection', async () => {
|
|
144
|
+
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
145
|
+
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
146
|
+
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
147
|
+
|
|
148
|
+
const out = await service.decide(request.id, { decision: 'reject', actorId: 'u1' }, SYSTEM_CTX);
|
|
149
|
+
|
|
150
|
+
expect(out).toMatchObject({ finalized: true, decision: 'reject', resumed: true });
|
|
151
|
+
expect(marks).toEqual(['on_rejected']);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('holds a unanimous step until every approver acts, then resumes', async () => {
|
|
155
|
+
registerDecisionFlow(automation, [
|
|
156
|
+
{ type: 'user', value: 'u1' },
|
|
157
|
+
{ type: 'user', value: 'u2' },
|
|
158
|
+
], 'unanimous');
|
|
159
|
+
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
160
|
+
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
161
|
+
|
|
162
|
+
const first = await service.decide(request.id, { decision: 'approve', actorId: 'u1' }, SYSTEM_CTX);
|
|
163
|
+
expect(first.finalized).toBe(false);
|
|
164
|
+
expect(first.resumed).toBe(false);
|
|
165
|
+
expect(marks).toHaveLength(0);
|
|
166
|
+
|
|
167
|
+
const second = await service.decide(request.id, { decision: 'approve', actorId: 'u2' }, SYSTEM_CTX);
|
|
168
|
+
expect(second.finalized).toBe(true);
|
|
169
|
+
expect(second.resumed).toBe(true);
|
|
170
|
+
expect(marks).toEqual(['on_approved']);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rejects a decision from a non-approver', async () => {
|
|
174
|
+
registerDecisionFlow(automation, [{ type: 'user', value: 'u1' }]);
|
|
175
|
+
await automation.execute('deal_approval', { object: 'crm_deal', record: { id: 'd1' } });
|
|
176
|
+
const request = (await fake.find('sys_approval_request', { where: { status: 'pending' } }))[0];
|
|
177
|
+
|
|
178
|
+
await expect(
|
|
179
|
+
service.decideNode(request.id, { decision: 'approve', actorId: 'intruder' }, { isSystem: false, roles: [], permissions: [] } as any),
|
|
180
|
+
).rejects.toThrow(/FORBIDDEN/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Approval-as-flow-node provider (ADR-0019).
|
|
5
|
+
*
|
|
6
|
+
* Registers an `approval` node executor on the automation engine so an approval
|
|
7
|
+
* rides the one flow engine as a durable-pause node:
|
|
8
|
+
*
|
|
9
|
+
* 1. On entry the node opens a `sys_approval_request` (reusing the mature
|
|
10
|
+
* approver-resolution / audit / lock / status-mirror machinery) and returns
|
|
11
|
+
* `{ suspend: true }` — the engine persists the run and stops traversal.
|
|
12
|
+
* 2. A decision (`ApprovalService.decide`) finalizes the request and resumes
|
|
13
|
+
* the run down the matching `approve` / `reject` out-edge.
|
|
14
|
+
*
|
|
15
|
+
* The approval *state* (request/action rows) stays first-class and owned by this
|
|
16
|
+
* plugin — a flow-run log can't drive an inbox / recall / audit. Only the
|
|
17
|
+
* orchestration (when to pause, which branch to take) moves onto the engine.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
defineActionDescriptor,
|
|
22
|
+
ApprovalNodeConfigSchema,
|
|
23
|
+
getApprovalNodeConfigJsonSchema,
|
|
24
|
+
APPROVAL_NODE_TYPE,
|
|
25
|
+
type ApprovalNodeConfig,
|
|
26
|
+
} from '@objectstack/spec/automation';
|
|
27
|
+
import type { SharingExecutionContext } from '@objectstack/spec/contracts';
|
|
28
|
+
import type { ApprovalService } from './approval-service.js';
|
|
29
|
+
|
|
30
|
+
/** Minimal surface of the automation engine this provider depends on. */
|
|
31
|
+
export interface ApprovalAutomationSurface {
|
|
32
|
+
registerNodeExecutor(executor: {
|
|
33
|
+
type: string;
|
|
34
|
+
descriptor?: unknown;
|
|
35
|
+
execute(node: any, variables: Map<string, unknown>, context: any): Promise<{
|
|
36
|
+
success: boolean;
|
|
37
|
+
output?: Record<string, unknown>;
|
|
38
|
+
error?: string;
|
|
39
|
+
suspend?: boolean;
|
|
40
|
+
correlation?: string;
|
|
41
|
+
}>;
|
|
42
|
+
}): void;
|
|
43
|
+
resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface MinimalLogger {
|
|
47
|
+
info?: (msg: any, ...rest: any[]) => void;
|
|
48
|
+
warn?: (msg: any, ...rest: any[]) => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Register the `approval` node executor on the automation engine. Idempotent at
|
|
55
|
+
* the engine level (re-registering replaces). Safe to skip when no automation
|
|
56
|
+
* service is present.
|
|
57
|
+
*/
|
|
58
|
+
export function registerApprovalNode(
|
|
59
|
+
automation: ApprovalAutomationSurface,
|
|
60
|
+
service: ApprovalService,
|
|
61
|
+
logger?: MinimalLogger,
|
|
62
|
+
): void {
|
|
63
|
+
automation.registerNodeExecutor({
|
|
64
|
+
type: APPROVAL_NODE_TYPE,
|
|
65
|
+
descriptor: defineActionDescriptor({
|
|
66
|
+
type: APPROVAL_NODE_TYPE,
|
|
67
|
+
version: '1.0.0',
|
|
68
|
+
name: 'Approval',
|
|
69
|
+
description: 'Route a record for human approval; suspends the flow until a decision, '
|
|
70
|
+
+ 'then continues down the approve / reject branch.',
|
|
71
|
+
icon: 'check-circle',
|
|
72
|
+
category: 'human',
|
|
73
|
+
paradigms: ['flow'],
|
|
74
|
+
source: 'plugin',
|
|
75
|
+
// Human decision: the run suspends here awaiting an external reply.
|
|
76
|
+
supportsPause: true,
|
|
77
|
+
isAsync: true,
|
|
78
|
+
// Publish the node's config contract (ADR-0018 §configSchema) so the
|
|
79
|
+
// Studio flow designer renders the Approval property form from the engine
|
|
80
|
+
// rather than a hardcoded client form — the engine owns the shape.
|
|
81
|
+
configSchema: getApprovalNodeConfigJsonSchema(),
|
|
82
|
+
}),
|
|
83
|
+
async execute(node, variables, context) {
|
|
84
|
+
const parsed = ApprovalNodeConfigSchema.safeParse(node.config ?? {});
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
const msg = parsed.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('; ');
|
|
87
|
+
return { success: false, error: `Approval node '${node.id}' has invalid config: ${msg}` };
|
|
88
|
+
}
|
|
89
|
+
const config = parsed.data as ApprovalNodeConfig;
|
|
90
|
+
|
|
91
|
+
const runId = variables.get('$runId');
|
|
92
|
+
const record = (variables.get('$record') ?? context?.record ?? {}) as Record<string, unknown>;
|
|
93
|
+
const object = (context?.object ?? (record as any)?.object_name) as string | undefined;
|
|
94
|
+
const recordId = (record as any)?.id as string | undefined;
|
|
95
|
+
|
|
96
|
+
if (!runId) return { success: false, error: `Approval node '${node.id}': missing $runId` };
|
|
97
|
+
if (!object) return { success: false, error: `Approval node '${node.id}': no target object in context` };
|
|
98
|
+
if (!recordId) return { success: false, error: `Approval node '${node.id}': no record id in $record` };
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const request = await service.openNodeRequest({
|
|
102
|
+
object,
|
|
103
|
+
recordId: String(recordId),
|
|
104
|
+
runId: String(runId),
|
|
105
|
+
nodeId: node.id,
|
|
106
|
+
config,
|
|
107
|
+
flowName: context?.flowName,
|
|
108
|
+
submitterId: context?.userId ?? null,
|
|
109
|
+
record,
|
|
110
|
+
organizationId: context?.organizationId ?? context?.tenantId ?? null,
|
|
111
|
+
}, {
|
|
112
|
+
...SYSTEM_CTX,
|
|
113
|
+
userId: context?.userId,
|
|
114
|
+
organizationId: context?.organizationId,
|
|
115
|
+
tenantId: context?.tenantId,
|
|
116
|
+
} as unknown as SharingExecutionContext);
|
|
117
|
+
|
|
118
|
+
logger?.info?.('[approvals] approval node suspended run', {
|
|
119
|
+
node: node.id, request: request.id, run: String(runId),
|
|
120
|
+
});
|
|
121
|
+
// Suspend the run; the request id is the correlation key surfaced on
|
|
122
|
+
// the suspended-run record for lookup.
|
|
123
|
+
return { success: true, suspend: true, correlation: request.id };
|
|
124
|
+
} catch (err: any) {
|
|
125
|
+
return { success: false, error: `Approval node '${node.id}': ${err?.message ?? String(err)}` };
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
logger?.info?.('[approvals] approval node executor registered');
|
|
131
|
+
}
|