@objectstack/plugin-approvals 4.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 +22 -0
- package/CHANGELOG.md +12 -0
- package/LICENSE +202 -0
- package/dist/index.d.mts +166 -0
- package/dist/index.d.ts +166 -0
- package/dist/index.js +1099 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +1078 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +37 -0
- package/src/action-executor.ts +313 -0
- package/src/approval-service.test.ts +337 -0
- package/src/approval-service.ts +731 -0
- package/src/approvals-plugin.ts +114 -0
- package/src/index.ts +36 -0
- package/src/lifecycle-hooks.ts +250 -0
- package/src/phase-b.test.ts +263 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
+
import {
|
|
5
|
+
SysApprovalProcess,
|
|
6
|
+
SysApprovalRequest,
|
|
7
|
+
SysApprovalAction,
|
|
8
|
+
} from '@objectstack/platform-objects/audit';
|
|
9
|
+
import { ApprovalService, type ApprovalEngine } from './approval-service.js';
|
|
10
|
+
import { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js';
|
|
11
|
+
|
|
12
|
+
export interface ApprovalsPluginOptions {
|
|
13
|
+
/** Disable runtime registration (schemas still register). */
|
|
14
|
+
disableService?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Disable Phase B auto-trigger / lock hooks. Schema definition stays
|
|
17
|
+
* intact; only the engine-level wiring is suppressed. Useful when a
|
|
18
|
+
* caller wants the manual API only (e.g. tests).
|
|
19
|
+
*/
|
|
20
|
+
disableAutoHooks?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* ApprovalsServicePlugin — registers sys_approval_{process,request,action},
|
|
25
|
+
* the `approvals` service, and Phase B lifecycle hooks (auto-trigger,
|
|
26
|
+
* record lock, status mirror). SLA escalation dispatcher is a later
|
|
27
|
+
* milestone.
|
|
28
|
+
*/
|
|
29
|
+
export class ApprovalsServicePlugin implements Plugin {
|
|
30
|
+
name = 'com.objectstack.service.approvals';
|
|
31
|
+
version = '1.0.0';
|
|
32
|
+
type = 'standard';
|
|
33
|
+
dependencies = ['com.objectstack.engine.objectql'];
|
|
34
|
+
|
|
35
|
+
private readonly options: ApprovalsPluginOptions;
|
|
36
|
+
private service?: ApprovalService;
|
|
37
|
+
private engine?: any;
|
|
38
|
+
private logger?: any;
|
|
39
|
+
|
|
40
|
+
constructor(options: ApprovalsPluginOptions = {}) {
|
|
41
|
+
this.options = options;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async init(ctx: PluginContext): Promise<void> {
|
|
45
|
+
ctx.getService<{ register(m: any): void }>('manifest').register({
|
|
46
|
+
id: 'com.objectstack.service.approvals',
|
|
47
|
+
name: 'Approvals Service',
|
|
48
|
+
version: '1.0.0',
|
|
49
|
+
type: 'plugin',
|
|
50
|
+
scope: 'system',
|
|
51
|
+
defaultDatasource: 'cloud',
|
|
52
|
+
namespace: 'sys',
|
|
53
|
+
objects: [SysApprovalProcess, SysApprovalRequest, SysApprovalAction],
|
|
54
|
+
});
|
|
55
|
+
ctx.logger.info('ApprovalsServicePlugin: schemas registered');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async start(ctx: PluginContext): Promise<void> {
|
|
59
|
+
if (this.options.disableService) return;
|
|
60
|
+
let engine: any = null;
|
|
61
|
+
try { engine = ctx.getService<any>('objectql'); }
|
|
62
|
+
catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
|
|
63
|
+
if (!engine) {
|
|
64
|
+
ctx.logger.warn('ApprovalsServicePlugin: no ObjectQL engine — service NOT registered');
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
this.engine = engine;
|
|
68
|
+
this.logger = ctx.logger;
|
|
69
|
+
|
|
70
|
+
this.service = new ApprovalService({
|
|
71
|
+
engine: engine as ApprovalEngine,
|
|
72
|
+
logger: ctx.logger,
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
if (!this.options.disableAutoHooks) {
|
|
76
|
+
// Re-bind hooks on every registry mutation.
|
|
77
|
+
this.service.setRegistryChangeHandler(() => this.rebindHooks());
|
|
78
|
+
// Initial bind happens once the kernel is ready so the AppPlugin's
|
|
79
|
+
// declarative process seeder has already populated sys_approval_process.
|
|
80
|
+
const hookOn = (ctx as any).hook ?? (ctx as any).on;
|
|
81
|
+
if (typeof hookOn === 'function') {
|
|
82
|
+
try {
|
|
83
|
+
hookOn.call(ctx, 'kernel:ready', async () => { await this.rebindHooks(); });
|
|
84
|
+
} catch {
|
|
85
|
+
// Fall through to immediate bind (no kernel:ready event).
|
|
86
|
+
await this.rebindHooks();
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
await this.rebindHooks();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
ctx.registerService('approvals', this.service);
|
|
94
|
+
ctx.logger.info('ApprovalsServicePlugin: service registered');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private async rebindHooks(): Promise<void> {
|
|
98
|
+
if (!this.engine || !this.service) return;
|
|
99
|
+
try {
|
|
100
|
+
unbindAllHooks(this.engine);
|
|
101
|
+
const processes = await this.service.listProcesses({ activeOnly: true }, { isSystem: true, roles: [], permissions: [] } as any);
|
|
102
|
+
bindProcessHooks(this.engine, this.service, processes, this.logger);
|
|
103
|
+
} catch (err: any) {
|
|
104
|
+
this.logger?.warn?.('[approvals] rebindHooks failed', { error: err?.message });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async stop(_ctx: PluginContext): Promise<void> {
|
|
109
|
+
if (this.engine) {
|
|
110
|
+
try { unbindAllHooks(this.engine); } catch { /* ignore */ }
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @objectstack/plugin-approvals
|
|
5
|
+
*
|
|
6
|
+
* Multi-step approval engine for ObjectStack.
|
|
7
|
+
* Persists sys_approval_process / sys_approval_request / sys_approval_action
|
|
8
|
+
* and drives the cycle: submit → review → approve/reject → effects.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
SysApprovalProcess,
|
|
13
|
+
SysApprovalRequest,
|
|
14
|
+
SysApprovalAction,
|
|
15
|
+
} from '@objectstack/platform-objects/audit';
|
|
16
|
+
export {
|
|
17
|
+
ApprovalService,
|
|
18
|
+
type ApprovalEngine,
|
|
19
|
+
type ApprovalClock,
|
|
20
|
+
type ApprovalServiceOptions,
|
|
21
|
+
} from './approval-service.js';
|
|
22
|
+
export {
|
|
23
|
+
ApprovalsServicePlugin,
|
|
24
|
+
type ApprovalsPluginOptions,
|
|
25
|
+
} from './approvals-plugin.js';
|
|
26
|
+
export type {
|
|
27
|
+
IApprovalService,
|
|
28
|
+
ApprovalProcessRow,
|
|
29
|
+
ApprovalRequestRow,
|
|
30
|
+
ApprovalActionRow,
|
|
31
|
+
ApprovalDecisionInput,
|
|
32
|
+
ApprovalDecisionResult,
|
|
33
|
+
ApprovalStatus,
|
|
34
|
+
DefineApprovalProcessInput,
|
|
35
|
+
SubmitApprovalInput,
|
|
36
|
+
} from '@objectstack/spec/contracts';
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lifecycle Hooks — Phase B auto-takeover.
|
|
5
|
+
*
|
|
6
|
+
* For each active ApprovalProcess we bind three hooks on its target object:
|
|
7
|
+
*
|
|
8
|
+
* 1. `afterInsert` — evaluate `entryCriteria` against the new record;
|
|
9
|
+
* if truthy and no pending request exists, auto-submit one.
|
|
10
|
+
* 2. `afterUpdate` — same as above but for updates that newly satisfy
|
|
11
|
+
* criteria (e.g. amount edited above threshold).
|
|
12
|
+
* 3. `beforeUpdate` — when `lockRecord=true`, block edits to a record
|
|
13
|
+
* that has a pending request, EXCEPT when the only fields being
|
|
14
|
+
* changed are the configured `approvalStatusField` (so the engine's
|
|
15
|
+
* own status mirror is not blocked).
|
|
16
|
+
*
|
|
17
|
+
* All hooks are registered with `packageId: 'plugin-approvals:auto'` so
|
|
18
|
+
* that re-bind on `defineProcess`/`deleteProcess` can call
|
|
19
|
+
* `engine.unregisterHooksByPackage(...)` first.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { ExpressionEngine } from '@objectstack/formula';
|
|
23
|
+
import type { Expression } from '@objectstack/spec';
|
|
24
|
+
import type { ApprovalProcessRow } from '@objectstack/spec/contracts';
|
|
25
|
+
import type { ApprovalService } from './approval-service.js';
|
|
26
|
+
|
|
27
|
+
export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:auto';
|
|
28
|
+
|
|
29
|
+
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
|
|
30
|
+
|
|
31
|
+
interface MinimalEngine {
|
|
32
|
+
registerHook(event: string, handler: (ctx: any) => any | Promise<any>, options?: {
|
|
33
|
+
object?: string | string[];
|
|
34
|
+
priority?: number;
|
|
35
|
+
packageId?: string;
|
|
36
|
+
}): void;
|
|
37
|
+
unregisterHooksByPackage(packageId: string): number;
|
|
38
|
+
find<T = any>(object: string, args: any, opts?: any): Promise<T[]>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface MinimalLogger {
|
|
42
|
+
debug?: (msg: any, ...rest: any[]) => void;
|
|
43
|
+
info?: (msg: any, ...rest: any[]) => void;
|
|
44
|
+
warn?: (msg: any, ...rest: any[]) => void;
|
|
45
|
+
error?: (msg: any, ...rest: any[]) => void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Evaluate an entry criteria expression against a record. Returns `true`
|
|
50
|
+
* when no criteria is set (matches everything). Returns `false` on
|
|
51
|
+
* evaluation failure (fail-closed — better to skip than auto-submit on a
|
|
52
|
+
* broken expression).
|
|
53
|
+
*/
|
|
54
|
+
function evaluateCriteria(criteria: unknown, record: Record<string, unknown>, logger?: MinimalLogger): boolean {
|
|
55
|
+
if (criteria == null || criteria === '' ) return true;
|
|
56
|
+
let expr: Expression;
|
|
57
|
+
if (typeof criteria === 'string') {
|
|
58
|
+
expr = { dialect: 'cel', source: criteria };
|
|
59
|
+
} else if (typeof criteria === 'object' && (criteria as any).dialect) {
|
|
60
|
+
expr = criteria as Expression;
|
|
61
|
+
} else {
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
if (!expr.source || !expr.source.trim()) return true;
|
|
65
|
+
const r = ExpressionEngine.evaluate<boolean>(expr, { record });
|
|
66
|
+
if (!r.ok) {
|
|
67
|
+
logger?.warn?.('[approvals] entryCriteria evaluation failed; skipping auto-submit', {
|
|
68
|
+
source: expr.source,
|
|
69
|
+
error: r.error.message,
|
|
70
|
+
});
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return Boolean(r.value);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Does this record already have a pending approval request? */
|
|
77
|
+
async function hasPendingRequest(
|
|
78
|
+
engine: MinimalEngine,
|
|
79
|
+
objectName: string,
|
|
80
|
+
recordId: string,
|
|
81
|
+
): Promise<boolean> {
|
|
82
|
+
try {
|
|
83
|
+
const rows = await engine.find('sys_approval_request', {
|
|
84
|
+
where: { object_name: objectName, record_id: String(recordId), status: 'pending' },
|
|
85
|
+
limit: 1,
|
|
86
|
+
} as any);
|
|
87
|
+
return Array.isArray(rows) && rows.length > 0;
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Bind auto-trigger + lock hooks for the supplied active processes.
|
|
95
|
+
* Caller is responsible for calling `unbindAll` first if re-binding.
|
|
96
|
+
*/
|
|
97
|
+
export function bindProcessHooks(
|
|
98
|
+
engine: MinimalEngine,
|
|
99
|
+
service: ApprovalService,
|
|
100
|
+
processes: ApprovalProcessRow[],
|
|
101
|
+
logger?: MinimalLogger,
|
|
102
|
+
): void {
|
|
103
|
+
// Group processes by object so we can register one hook per object
|
|
104
|
+
// and fan out internally — keeps the engine's hook map compact.
|
|
105
|
+
const byObject = new Map<string, ApprovalProcessRow[]>();
|
|
106
|
+
for (const p of processes) {
|
|
107
|
+
if (!(p as any).active && !(p as any).is_active) continue;
|
|
108
|
+
if (!p.object_name) continue;
|
|
109
|
+
const list = byObject.get(p.object_name) ?? [];
|
|
110
|
+
list.push(p);
|
|
111
|
+
byObject.set(p.object_name, list);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [objectName, procs] of byObject.entries()) {
|
|
115
|
+
// ---- auto-trigger (afterInsert) ----
|
|
116
|
+
engine.registerHook('afterInsert', async (ctx: any) => {
|
|
117
|
+
try {
|
|
118
|
+
const record = (ctx?.result ?? ctx?.input?.data ?? {}) as Record<string, unknown>;
|
|
119
|
+
const id = String((record as any)?.id ?? '');
|
|
120
|
+
if (!id) return;
|
|
121
|
+
for (const proc of procs) {
|
|
122
|
+
await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger);
|
|
123
|
+
}
|
|
124
|
+
} catch (err: any) {
|
|
125
|
+
logger?.warn?.('[approvals] afterInsert auto-trigger failed', { error: err?.message });
|
|
126
|
+
}
|
|
127
|
+
}, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 });
|
|
128
|
+
|
|
129
|
+
// ---- auto-trigger (afterUpdate) ----
|
|
130
|
+
engine.registerHook('afterUpdate', async (ctx: any) => {
|
|
131
|
+
// Ignore engine self-writes (status mirror, field_update from
|
|
132
|
+
// post-actions, etc) — otherwise post-finalize updates would loop
|
|
133
|
+
// a fresh approval on every state change.
|
|
134
|
+
if ((ctx?.session as any)?.isSystem) return;
|
|
135
|
+
try {
|
|
136
|
+
const result = (ctx?.result ?? {}) as Record<string, unknown>;
|
|
137
|
+
const id = String((ctx?.input?.id ?? (result as any)?.id ?? '') as string);
|
|
138
|
+
if (!id) return;
|
|
139
|
+
// result may be { affected: 1 } for some drivers; merge previous+input.data as the
|
|
140
|
+
// best-effort record snapshot for criteria evaluation.
|
|
141
|
+
const record: Record<string, unknown> = {
|
|
142
|
+
...(ctx?.previous ?? {}),
|
|
143
|
+
...((result as any)?.id ? result : {}),
|
|
144
|
+
...((ctx?.input?.data ?? {}) as Record<string, unknown>),
|
|
145
|
+
id,
|
|
146
|
+
};
|
|
147
|
+
for (const proc of procs) {
|
|
148
|
+
await tryAutoSubmit(engine, service, proc, objectName, id, record, ctx, logger);
|
|
149
|
+
}
|
|
150
|
+
} catch (err: any) {
|
|
151
|
+
logger?.warn?.('[approvals] afterUpdate auto-trigger failed', { error: err?.message });
|
|
152
|
+
}
|
|
153
|
+
}, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 200 });
|
|
154
|
+
|
|
155
|
+
// ---- record lock (beforeUpdate) ----
|
|
156
|
+
const lockProcs = procs.filter((p) => (p.definition as any)?.lockRecord !== false);
|
|
157
|
+
if (lockProcs.length === 0) continue;
|
|
158
|
+
engine.registerHook('beforeUpdate', async (ctx: any) => {
|
|
159
|
+
const id = String((ctx?.input?.id ?? '') as string);
|
|
160
|
+
if (!id) return;
|
|
161
|
+
const data = (ctx?.input?.data ?? {}) as Record<string, unknown>;
|
|
162
|
+
const changedFields = Object.keys(data).filter((k) => k !== 'id' && k !== 'updated_at');
|
|
163
|
+
if (changedFields.length === 0) return;
|
|
164
|
+
|
|
165
|
+
// Allow engine self-writes (status mirror, field_update from actions, etc).
|
|
166
|
+
if ((ctx?.session as any)?.isSystem) return;
|
|
167
|
+
|
|
168
|
+
// Allow when every changed field is an approval status mirror.
|
|
169
|
+
const mirrorFields = new Set<string>();
|
|
170
|
+
for (const p of lockProcs) {
|
|
171
|
+
const f = (p.definition as any)?.approvalStatusField;
|
|
172
|
+
if (typeof f === 'string' && f) mirrorFields.add(f);
|
|
173
|
+
}
|
|
174
|
+
const onlyMirror = changedFields.every((f) => mirrorFields.has(f));
|
|
175
|
+
if (onlyMirror) return;
|
|
176
|
+
|
|
177
|
+
// Allow admin override: roles include 'admin'.
|
|
178
|
+
const roles = (ctx?.session?.roles ?? []) as string[];
|
|
179
|
+
if (Array.isArray(roles) && roles.includes('admin')) return;
|
|
180
|
+
|
|
181
|
+
const pending = await hasPendingRequest(engine, objectName, id);
|
|
182
|
+
if (!pending) return;
|
|
183
|
+
|
|
184
|
+
const err: any = new Error('RECORD_LOCKED: record is locked while an approval is in progress');
|
|
185
|
+
err.code = 'RECORD_LOCKED';
|
|
186
|
+
err.statusCode = 409;
|
|
187
|
+
throw err;
|
|
188
|
+
}, { object: objectName, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
logger?.info?.('[approvals] lifecycle hooks bound', {
|
|
192
|
+
objects: Array.from(byObject.keys()),
|
|
193
|
+
processCount: processes.length,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Unregister every hook the auto-trigger module ever registered. */
|
|
198
|
+
export function unbindAllHooks(engine: MinimalEngine): number {
|
|
199
|
+
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function tryAutoSubmit(
|
|
203
|
+
engine: MinimalEngine,
|
|
204
|
+
service: ApprovalService,
|
|
205
|
+
process: ApprovalProcessRow,
|
|
206
|
+
objectName: string,
|
|
207
|
+
recordId: string,
|
|
208
|
+
record: Record<string, unknown>,
|
|
209
|
+
ctx: any,
|
|
210
|
+
logger?: MinimalLogger,
|
|
211
|
+
): Promise<void> {
|
|
212
|
+
try {
|
|
213
|
+
const criteria = (process.definition as any)?.entryCriteria;
|
|
214
|
+
const passes = evaluateCriteria(criteria, record, logger);
|
|
215
|
+
if (!passes) return;
|
|
216
|
+
if (await hasPendingRequest(engine, objectName, recordId)) return;
|
|
217
|
+
// Guard: if the record's mirror status field is already a terminal
|
|
218
|
+
// state (approved / rejected / recalled), do NOT auto-submit again —
|
|
219
|
+
// otherwise every post-finalize edit would loop a fresh approval.
|
|
220
|
+
const statusField = (process.definition as any)?.approvalStatusField;
|
|
221
|
+
if (statusField) {
|
|
222
|
+
const current = (record as any)?.[statusField];
|
|
223
|
+
if (current === 'approved' || current === 'rejected' || current === 'recalled') return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const submitterId = (ctx?.session?.userId ?? null) as string | null;
|
|
227
|
+
const submitterOrg = (ctx?.session?.tenantId ?? ctx?.session?.organizationId ?? null) as string | null;
|
|
228
|
+
await service.submit({
|
|
229
|
+
object: objectName,
|
|
230
|
+
recordId,
|
|
231
|
+
processName: process.name,
|
|
232
|
+
payload: record,
|
|
233
|
+
submitterId,
|
|
234
|
+
}, { ...SYSTEM_CTX, userId: submitterId ?? undefined, organizationId: submitterOrg ?? undefined, tenantId: submitterOrg ?? undefined } as any);
|
|
235
|
+
|
|
236
|
+
logger?.info?.('[approvals] auto-submitted approval', {
|
|
237
|
+
process: process.name,
|
|
238
|
+
object: objectName,
|
|
239
|
+
record: recordId,
|
|
240
|
+
});
|
|
241
|
+
} catch (err: any) {
|
|
242
|
+
if (err?.code === 'DUPLICATE_REQUEST') return;
|
|
243
|
+
logger?.warn?.('[approvals] auto-submit failed', {
|
|
244
|
+
process: process.name,
|
|
245
|
+
object: objectName,
|
|
246
|
+
record: recordId,
|
|
247
|
+
error: err?.message ?? String(err),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Phase B integration tests.
|
|
5
|
+
*
|
|
6
|
+
* - status mirror (`approvalStatusField` on the business record)
|
|
7
|
+
* - process-level `onSubmit / onFinalApprove / onFinalReject / onRecall`
|
|
8
|
+
* - step-level `onApprove / onReject`
|
|
9
|
+
* - `inbox_notify` action writes `sys_notification` rows
|
|
10
|
+
* - `field_update` action writes the business record (token interpolation)
|
|
11
|
+
* - lifecycle hooks: afterInsert auto-submit, lock on beforeUpdate, allow
|
|
12
|
+
* status-mirror writes through, allow admin override.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
16
|
+
import { ApprovalService } from './approval-service.js';
|
|
17
|
+
import { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js';
|
|
18
|
+
|
|
19
|
+
interface FakeRow { [k: string]: any }
|
|
20
|
+
|
|
21
|
+
function makeFakeEngine() {
|
|
22
|
+
const tables: Record<string, FakeRow[]> = {};
|
|
23
|
+
const ensure = (n: string) => (tables[n] ??= []);
|
|
24
|
+
const hooks: Record<string, Array<{ handler: (ctx: any) => any | Promise<any>; object?: string | string[]; packageId?: string }>> = {};
|
|
25
|
+
|
|
26
|
+
function matches(row: FakeRow, filter: any): boolean {
|
|
27
|
+
if (!filter || typeof filter !== 'object') return true;
|
|
28
|
+
for (const [k, v] of Object.entries(filter)) {
|
|
29
|
+
if (row[k] !== v) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function fire(event: string, ctx: any) {
|
|
35
|
+
const list = hooks[event] ?? [];
|
|
36
|
+
for (const h of list) {
|
|
37
|
+
if (h.object) {
|
|
38
|
+
const objs = Array.isArray(h.object) ? h.object : [h.object];
|
|
39
|
+
if (!objs.includes(ctx.object)) continue;
|
|
40
|
+
}
|
|
41
|
+
await h.handler(ctx);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
_tables: tables,
|
|
47
|
+
_hooks: hooks,
|
|
48
|
+
async find(object: string, options?: any, _opts?: any) {
|
|
49
|
+
const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
|
|
50
|
+
return rows.slice(0, options?.limit ?? 1000);
|
|
51
|
+
},
|
|
52
|
+
async insert(object: string, data: any, opts?: any) {
|
|
53
|
+
const row = { ...data };
|
|
54
|
+
ensure(object).push(row);
|
|
55
|
+
const ctx = { object, event: 'afterInsert', result: row, input: { data: row }, session: opts?.context ?? {} };
|
|
56
|
+
await fire('afterInsert', ctx);
|
|
57
|
+
return row;
|
|
58
|
+
},
|
|
59
|
+
async update(object: string, idOrData: any, opts?: any) {
|
|
60
|
+
const data = typeof idOrData === 'object' ? idOrData : opts;
|
|
61
|
+
const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
|
|
62
|
+
// beforeUpdate (skip status-mirror writes from system context — the
|
|
63
|
+
// hook itself decides via the `data.keys ⊆ approvalStatusField` rule).
|
|
64
|
+
const beforeCtx = { object, event: 'beforeUpdate', input: { id, data }, session: opts?.context ?? {} };
|
|
65
|
+
await fire('beforeUpdate', beforeCtx);
|
|
66
|
+
const table = ensure(object);
|
|
67
|
+
const i = table.findIndex(r => r.id === id);
|
|
68
|
+
if (i >= 0) table[i] = { ...table[i], ...data };
|
|
69
|
+
const after = table[i];
|
|
70
|
+
const afterCtx = { object, event: 'afterUpdate', input: { id, data }, result: after, session: opts?.context ?? {} };
|
|
71
|
+
await fire('afterUpdate', afterCtx);
|
|
72
|
+
return after;
|
|
73
|
+
},
|
|
74
|
+
async delete(object: string, options?: any) {
|
|
75
|
+
const table = ensure(object);
|
|
76
|
+
const id = options?.where?.id ?? options?.id;
|
|
77
|
+
const i = table.findIndex(r => r.id === id);
|
|
78
|
+
if (i >= 0) table.splice(i, 1);
|
|
79
|
+
return { id };
|
|
80
|
+
},
|
|
81
|
+
registerHook(event: string, handler: any, options?: any) {
|
|
82
|
+
(hooks[event] ??= []).push({ handler, object: options?.object, packageId: options?.packageId });
|
|
83
|
+
},
|
|
84
|
+
unregisterHooksByPackage(packageId: string) {
|
|
85
|
+
let removed = 0;
|
|
86
|
+
for (const ev of Object.keys(hooks)) {
|
|
87
|
+
const before = hooks[ev].length;
|
|
88
|
+
hooks[ev] = hooks[ev].filter(h => h.packageId !== packageId);
|
|
89
|
+
removed += before - hooks[ev].length;
|
|
90
|
+
}
|
|
91
|
+
return removed;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const SYS = { isSystem: true, roles: [], permissions: [] };
|
|
97
|
+
const USR = { userId: 'submitter', roles: [], permissions: [] };
|
|
98
|
+
|
|
99
|
+
function processWithMirror() {
|
|
100
|
+
return {
|
|
101
|
+
name: 'discount_approval',
|
|
102
|
+
label: 'Discount Approval',
|
|
103
|
+
object: 'opportunity',
|
|
104
|
+
active: true,
|
|
105
|
+
approvalStatusField: 'approval_status',
|
|
106
|
+
lockRecord: true,
|
|
107
|
+
entryCriteria: 'record.amount > 50000',
|
|
108
|
+
onSubmit: [
|
|
109
|
+
{
|
|
110
|
+
type: 'inbox_notify' as const,
|
|
111
|
+
name: 'notify_pending',
|
|
112
|
+
config: {
|
|
113
|
+
to: 'pending_approvers',
|
|
114
|
+
title: 'Discount needs approval',
|
|
115
|
+
body: 'Opportunity {record_id} amount review',
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
onFinalApprove: [
|
|
120
|
+
{ type: 'field_update' as const, name: 'close_won', config: { field: 'stage', value: 'closed_won' } },
|
|
121
|
+
],
|
|
122
|
+
onFinalReject: [
|
|
123
|
+
{ type: 'inbox_notify' as const, name: 'tell_submitter', config: { to: 'submitter', title: 'Rejected', body: 'Sorry' } },
|
|
124
|
+
],
|
|
125
|
+
onRecall: [
|
|
126
|
+
{ type: 'inbox_notify' as const, name: 'recalled', config: { to: 'submitter', title: 'Recalled', body: 'Pulled back' } },
|
|
127
|
+
],
|
|
128
|
+
steps: [
|
|
129
|
+
{
|
|
130
|
+
name: 'sales_manager',
|
|
131
|
+
label: 'Sales Manager',
|
|
132
|
+
approvers: [{ type: 'user' as const, value: 'manager' }],
|
|
133
|
+
behavior: 'first_response' as const,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
describe('Phase B — approval auto-takeover', () => {
|
|
140
|
+
let engine: ReturnType<typeof makeFakeEngine>;
|
|
141
|
+
let svc: ApprovalService;
|
|
142
|
+
|
|
143
|
+
beforeEach(() => {
|
|
144
|
+
engine = makeFakeEngine();
|
|
145
|
+
svc = new ApprovalService({ engine: engine as any });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe('status mirror + actions', () => {
|
|
149
|
+
beforeEach(async () => {
|
|
150
|
+
// Seed a business record so syncStatusField can update it.
|
|
151
|
+
engine._tables.opportunity = [{ id: 'opp1', amount: 80000, stage: 'qualification', approval_status: 'not_submitted' }];
|
|
152
|
+
await svc.defineProcess({
|
|
153
|
+
name: 'discount_approval',
|
|
154
|
+
label: 'Discount Approval',
|
|
155
|
+
object: 'opportunity',
|
|
156
|
+
definition: processWithMirror(),
|
|
157
|
+
}, SYS as any);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('writes onSubmit notifications and mirrors status to the business record', async () => {
|
|
161
|
+
await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
|
|
162
|
+
|
|
163
|
+
// status mirrored.
|
|
164
|
+
const opp = engine._tables.opportunity[0];
|
|
165
|
+
expect(opp.approval_status).toBe('pending');
|
|
166
|
+
|
|
167
|
+
// inbox notification written to pending approvers.
|
|
168
|
+
const notes = engine._tables.sys_notification ?? [];
|
|
169
|
+
expect(notes.length).toBeGreaterThanOrEqual(1);
|
|
170
|
+
expect(notes.some(n => n.recipient_id === 'manager' && /Opportunity opp1/.test(n.body))).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('runs onFinalApprove field_update on finalize, mirrors status=approved', async () => {
|
|
174
|
+
const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
|
|
175
|
+
await svc.approve(submitted.id, { actorId: 'manager' }, SYS as any);
|
|
176
|
+
|
|
177
|
+
const opp = engine._tables.opportunity[0];
|
|
178
|
+
expect(opp.stage).toBe('closed_won');
|
|
179
|
+
expect(opp.approval_status).toBe('approved');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('runs onFinalReject inbox_notify on rejection, mirrors status=rejected', async () => {
|
|
183
|
+
const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
|
|
184
|
+
await svc.reject(submitted.id, { actorId: 'manager', comment: 'too low' }, SYS as any);
|
|
185
|
+
|
|
186
|
+
const opp = engine._tables.opportunity[0];
|
|
187
|
+
expect(opp.approval_status).toBe('rejected');
|
|
188
|
+
const notes = engine._tables.sys_notification ?? [];
|
|
189
|
+
expect(notes.some(n => n.recipient_id === 'submitter' && n.title === 'Rejected')).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('runs onRecall and mirrors status=recalled', async () => {
|
|
193
|
+
const submitted = await svc.submit({ object: 'opportunity', recordId: 'opp1', submitterId: 'submitter', payload: engine._tables.opportunity[0] }, USR as any);
|
|
194
|
+
await svc.recall(submitted.id, { actorId: 'submitter' }, USR as any);
|
|
195
|
+
|
|
196
|
+
const opp = engine._tables.opportunity[0];
|
|
197
|
+
expect(opp.approval_status).toBe('recalled');
|
|
198
|
+
const notes = engine._tables.sys_notification ?? [];
|
|
199
|
+
expect(notes.some(n => n.title === 'Recalled')).toBe(true);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
describe('lifecycle hooks', () => {
|
|
204
|
+
beforeEach(async () => {
|
|
205
|
+
await svc.defineProcess({
|
|
206
|
+
name: 'discount_approval',
|
|
207
|
+
label: 'Discount Approval',
|
|
208
|
+
object: 'opportunity',
|
|
209
|
+
definition: processWithMirror(),
|
|
210
|
+
}, SYS as any);
|
|
211
|
+
const procs = await svc.listProcesses({ activeOnly: true }, SYS as any);
|
|
212
|
+
bindProcessHooks(engine as any, svc, procs);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('auto-submits a request when an inserted record matches entryCriteria', async () => {
|
|
216
|
+
await engine.insert('opportunity', { id: 'opp_high', amount: 100000, stage: 'qualification' });
|
|
217
|
+
// Drain microtasks (insert kicks off the hook).
|
|
218
|
+
await new Promise(r => setTimeout(r, 0));
|
|
219
|
+
const requests = engine._tables.sys_approval_request ?? [];
|
|
220
|
+
expect(requests.length).toBe(1);
|
|
221
|
+
expect(requests[0].object_name).toBe('opportunity');
|
|
222
|
+
expect(requests[0].record_id).toBe('opp_high');
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('does NOT auto-submit when entryCriteria evaluates to false', async () => {
|
|
226
|
+
await engine.insert('opportunity', { id: 'opp_low', amount: 1000, stage: 'qualification' });
|
|
227
|
+
await new Promise(r => setTimeout(r, 0));
|
|
228
|
+
expect(engine._tables.sys_approval_request ?? []).toHaveLength(0);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('does NOT double-submit when criteria continues to be true on update', async () => {
|
|
232
|
+
await engine.insert('opportunity', { id: 'opp_dup', amount: 100000, stage: 'qualification' });
|
|
233
|
+
await new Promise(r => setTimeout(r, 0));
|
|
234
|
+
await engine.update('opportunity', { id: 'opp_dup', amount: 110000 }, { context: { ...SYS, roles: ['admin'] } });
|
|
235
|
+
await new Promise(r => setTimeout(r, 0));
|
|
236
|
+
expect((engine._tables.sys_approval_request ?? []).length).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('lock hook blocks edits to a locked record', async () => {
|
|
240
|
+
await engine.insert('opportunity', { id: 'opp_lock', amount: 100000, stage: 'qualification' });
|
|
241
|
+
await new Promise(r => setTimeout(r, 0));
|
|
242
|
+
await expect(
|
|
243
|
+
engine.update('opportunity', { id: 'opp_lock', stage: 'closed_won' }, { context: { userId: 'u1', roles: [] } }),
|
|
244
|
+
).rejects.toThrow(/RECORD_LOCKED/);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('lock hook allows admin role override', async () => {
|
|
248
|
+
await engine.insert('opportunity', { id: 'opp_admin', amount: 100000, stage: 'qualification' });
|
|
249
|
+
await new Promise(r => setTimeout(r, 0));
|
|
250
|
+
await expect(
|
|
251
|
+
engine.update('opportunity', { id: 'opp_admin', stage: 'closed_won' }, { context: { userId: 'admin', roles: ['admin'] } }),
|
|
252
|
+
).resolves.toBeTruthy();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it('unbindAllHooks removes registered hooks idempotently', async () => {
|
|
256
|
+
const removed = unbindAllHooks(engine as any);
|
|
257
|
+
expect(removed).toBeGreaterThan(0);
|
|
258
|
+
// Re-bind to default state for any later beforeEach.
|
|
259
|
+
const procs = await svc.listProcesses({ activeOnly: true }, SYS as any);
|
|
260
|
+
bindProcessHooks(engine as any, svc, procs);
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
});
|