@objectstack/plugin-approvals 7.3.0 → 7.4.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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +75 -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
package/src/approvals-plugin.ts
CHANGED
|
@@ -1,30 +1,31 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
import type { Plugin, PluginContext } from '@objectstack/core';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
SysApprovalRequest,
|
|
7
|
-
SysApprovalAction,
|
|
8
|
-
} from '@objectstack/platform-objects/audit';
|
|
4
|
+
import { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
5
|
+
import { SysApprovalAction } from './sys-approval-action.object.js';
|
|
9
6
|
import { ApprovalService, type ApprovalEngine } from './approval-service.js';
|
|
10
|
-
import {
|
|
7
|
+
import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
|
|
8
|
+
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
|
|
11
9
|
|
|
12
10
|
export interface ApprovalsPluginOptions {
|
|
13
11
|
/** Disable runtime registration (schemas still register). */
|
|
14
12
|
disableService?: boolean;
|
|
15
13
|
/**
|
|
16
|
-
* Disable
|
|
17
|
-
*
|
|
18
|
-
*
|
|
14
|
+
* Disable the record-lock hook. Schema + service stay intact; only the
|
|
15
|
+
* engine-level lock wiring is suppressed. Useful when a caller wants the
|
|
16
|
+
* manual API only (e.g. tests).
|
|
19
17
|
*/
|
|
20
18
|
disableAutoHooks?: boolean;
|
|
21
19
|
}
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
|
-
* ApprovalsServicePlugin — registers sys_approval_{
|
|
25
|
-
*
|
|
26
|
-
* record
|
|
27
|
-
*
|
|
22
|
+
* ApprovalsServicePlugin — registers sys_approval_{request,action}, the
|
|
23
|
+
* `approvals` service, the `approval` flow node executor (ADR-0019), and the
|
|
24
|
+
* record-lock hook.
|
|
25
|
+
*
|
|
26
|
+
* ADR-0019: approval is no longer a standalone process engine. A flow's
|
|
27
|
+
* Approval node opens a request and suspends the run; a decision via the
|
|
28
|
+
* service resumes it down the matching branch.
|
|
28
29
|
*/
|
|
29
30
|
export class ApprovalsServicePlugin implements Plugin {
|
|
30
31
|
name = 'com.objectstack.service.approvals';
|
|
@@ -35,7 +36,6 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
35
36
|
private readonly options: ApprovalsPluginOptions;
|
|
36
37
|
private service?: ApprovalService;
|
|
37
38
|
private engine?: any;
|
|
38
|
-
private logger?: any;
|
|
39
39
|
|
|
40
40
|
constructor(options: ApprovalsPluginOptions = {}) {
|
|
41
41
|
this.options = options;
|
|
@@ -50,8 +50,37 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
50
50
|
scope: 'system',
|
|
51
51
|
defaultDatasource: 'cloud',
|
|
52
52
|
namespace: 'sys',
|
|
53
|
-
objects: [
|
|
53
|
+
objects: [SysApprovalRequest, SysApprovalAction],
|
|
54
|
+
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
55
|
+
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
56
|
+
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
57
|
+
navigationContributions: [
|
|
58
|
+
{
|
|
59
|
+
app: 'setup',
|
|
60
|
+
group: 'group_approvals',
|
|
61
|
+
priority: 100,
|
|
62
|
+
items: [
|
|
63
|
+
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
|
|
64
|
+
{ id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
54
68
|
});
|
|
69
|
+
// ADR-0029 D8 — contribute this plugin's object translations to the i18n
|
|
70
|
+
// service on kernel:ready (the i18n plugin may register after this one).
|
|
71
|
+
if (typeof (ctx as any).hook === 'function') {
|
|
72
|
+
(ctx as any).hook('kernel:ready', async () => {
|
|
73
|
+
try {
|
|
74
|
+
const i18n = ctx.getService<any>('i18n');
|
|
75
|
+
if (i18n && typeof i18n.loadTranslations === 'function') {
|
|
76
|
+
const { ApprovalsTranslations } = await import('./translations/index.js');
|
|
77
|
+
for (const [locale, data] of Object.entries(ApprovalsTranslations)) {
|
|
78
|
+
i18n.loadTranslations(locale, data as Record<string, unknown>);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
} catch { /* i18n optional */ }
|
|
82
|
+
});
|
|
83
|
+
}
|
|
55
84
|
ctx.logger.info('ApprovalsServicePlugin: schemas registered');
|
|
56
85
|
}
|
|
57
86
|
|
|
@@ -65,57 +94,37 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
65
94
|
return;
|
|
66
95
|
}
|
|
67
96
|
this.engine = engine;
|
|
68
|
-
this.logger = ctx.logger;
|
|
69
|
-
|
|
70
|
-
// ADR-0009: try to wire the metadata repository for execution pinning.
|
|
71
|
-
// The approvals service degrades to the projection-table path if no
|
|
72
|
-
// metadata service is registered (e.g. in tests or minimal setups).
|
|
73
|
-
let metadataRepo: any;
|
|
74
|
-
try {
|
|
75
|
-
const meta = ctx.getService<any>('metadata');
|
|
76
|
-
metadataRepo = meta?.getRepository?.();
|
|
77
|
-
} catch { /* metadata plugin not loaded — fall back */ }
|
|
78
97
|
|
|
79
98
|
this.service = new ApprovalService({
|
|
80
99
|
engine: engine as ApprovalEngine,
|
|
81
100
|
logger: ctx.logger,
|
|
82
|
-
metadataRepo,
|
|
83
101
|
});
|
|
84
102
|
|
|
85
|
-
|
|
86
|
-
ctx.logger.info('ApprovalsServicePlugin: execution pinning enabled (ADR-0009)');
|
|
87
|
-
}
|
|
88
|
-
|
|
103
|
+
// Record lock: block edits to a record while it has a pending request.
|
|
89
104
|
if (!this.options.disableAutoHooks) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (typeof hookOn === 'function') {
|
|
96
|
-
try {
|
|
97
|
-
hookOn.call(ctx, 'kernel:ready', async () => { await this.rebindHooks(); });
|
|
98
|
-
} catch {
|
|
99
|
-
// Fall through to immediate bind (no kernel:ready event).
|
|
100
|
-
await this.rebindHooks();
|
|
101
|
-
}
|
|
102
|
-
} else {
|
|
103
|
-
await this.rebindHooks();
|
|
105
|
+
try {
|
|
106
|
+
unbindAllHooks(engine);
|
|
107
|
+
bindApprovalLockHook(engine, ctx.logger);
|
|
108
|
+
} catch (err: any) {
|
|
109
|
+
ctx.logger.warn?.('[approvals] failed to bind record-lock hook', { error: err?.message });
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
ctx.registerService('approvals', this.service);
|
|
108
114
|
ctx.logger.info('ApprovalsServicePlugin: service registered');
|
|
109
|
-
}
|
|
110
115
|
|
|
111
|
-
|
|
112
|
-
|
|
116
|
+
// ADR-0019: contribute the `approval` node to the flow engine when one is
|
|
117
|
+
// present. The node lets a flow suspend on an approval and resume on
|
|
118
|
+
// decision; the service is wired to the same engine so `decide()` can
|
|
119
|
+
// resume the suspended run.
|
|
113
120
|
try {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
121
|
+
const automation = ctx.getService<ApprovalAutomationSurface>('automation');
|
|
122
|
+
if (automation && typeof automation.registerNodeExecutor === 'function') {
|
|
123
|
+
this.service.attachAutomation(automation);
|
|
124
|
+
registerApprovalNode(automation, this.service, ctx.logger);
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
ctx.logger.info('ApprovalsServicePlugin: no automation engine — approval node not registered');
|
|
119
128
|
}
|
|
120
129
|
}
|
|
121
130
|
|
|
@@ -125,4 +134,3 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
125
134
|
}
|
|
126
135
|
}
|
|
127
136
|
}
|
|
128
|
-
|
package/src/index.ts
CHANGED
|
@@ -3,34 +3,35 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* @objectstack/plugin-approvals
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Approval-as-flow-node runtime (ADR-0019). Persists sys_approval_request /
|
|
7
|
+
* sys_approval_action, resolves approvers, enforces the record lock, and
|
|
8
|
+
* records decisions that resume the owning flow run. Approval orchestration
|
|
9
|
+
* (when to pause, which branch to take) lives on the one automation engine via
|
|
10
|
+
* the `approval` node.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
|
-
export {
|
|
12
|
-
|
|
13
|
-
SysApprovalRequest,
|
|
14
|
-
SysApprovalAction,
|
|
15
|
-
} from '@objectstack/platform-objects/audit';
|
|
13
|
+
export { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
14
|
+
export { SysApprovalAction } from './sys-approval-action.object.js';
|
|
16
15
|
export {
|
|
17
16
|
ApprovalService,
|
|
18
17
|
type ApprovalEngine,
|
|
19
18
|
type ApprovalClock,
|
|
20
19
|
type ApprovalServiceOptions,
|
|
20
|
+
type ApprovalResumeSurface,
|
|
21
21
|
} from './approval-service.js';
|
|
22
22
|
export {
|
|
23
23
|
ApprovalsServicePlugin,
|
|
24
24
|
type ApprovalsPluginOptions,
|
|
25
25
|
} from './approvals-plugin.js';
|
|
26
|
+
export {
|
|
27
|
+
registerApprovalNode,
|
|
28
|
+
type ApprovalAutomationSurface,
|
|
29
|
+
} from './approval-node.js';
|
|
26
30
|
export type {
|
|
27
31
|
IApprovalService,
|
|
28
|
-
ApprovalProcessRow,
|
|
29
32
|
ApprovalRequestRow,
|
|
30
33
|
ApprovalActionRow,
|
|
31
34
|
ApprovalDecisionInput,
|
|
32
35
|
ApprovalDecisionResult,
|
|
33
36
|
ApprovalStatus,
|
|
34
|
-
DefineApprovalProcessInput,
|
|
35
|
-
SubmitApprovalInput,
|
|
36
37
|
} from '@objectstack/spec/contracts';
|
package/src/lifecycle-hooks.ts
CHANGED
|
@@ -1,32 +1,29 @@
|
|
|
1
1
|
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Lifecycle Hooks —
|
|
4
|
+
* Lifecycle Hooks — node-era record lock (ADR-0019).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Approval is now a flow node, so there is no per-object process registry to
|
|
7
|
+
* bind auto-trigger hooks against — a flow decides *when* to open an approval.
|
|
8
|
+
* What remains worth enforcing at the data layer is the **record lock**: while
|
|
9
|
+
* a record has a pending `sys_approval_request`, block edits to it.
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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).
|
|
11
|
+
* A single global `beforeUpdate` hook handles every object (the target object
|
|
12
|
+
* of an approval node is only known at flow-run time). For each update it:
|
|
16
13
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* `
|
|
14
|
+
* 1. Skips engine self-writes (status mirror) and `sys_approval_*` bookkeeping.
|
|
15
|
+
* 2. Looks up a pending request for `(object, recordId)`.
|
|
16
|
+
* 3. Reads the lock policy from that request's `node_config_json` snapshot:
|
|
17
|
+
* - `lockRecord === false` → allow.
|
|
18
|
+
* - otherwise block, EXCEPT when the only changed field is the configured
|
|
19
|
+
* `approvalStatusField` (so the status mirror is never blocked) or the
|
|
20
|
+
* caller is an `admin`.
|
|
21
|
+
*
|
|
22
|
+
* Registered under `packageId: 'plugin-approvals:lock'` so it can be cleanly
|
|
23
|
+
* unbound on plugin stop.
|
|
20
24
|
*/
|
|
21
25
|
|
|
22
|
-
|
|
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;
|
|
26
|
+
export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:lock';
|
|
30
27
|
|
|
31
28
|
interface MinimalEngine {
|
|
32
29
|
registerHook(event: string, handler: (ctx: any) => any | Promise<any>, options?: {
|
|
@@ -45,206 +42,74 @@ interface MinimalLogger {
|
|
|
45
42
|
error?: (msg: any, ...rest: any[]) => void;
|
|
46
43
|
}
|
|
47
44
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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;
|
|
45
|
+
function parseJson<T = any>(raw: unknown, fallback: T): T {
|
|
46
|
+
if (raw == null || raw === '') return fallback;
|
|
47
|
+
if (typeof raw === 'string') {
|
|
48
|
+
try { return JSON.parse(raw) as T; } catch { return fallback; }
|
|
63
49
|
}
|
|
64
|
-
|
|
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);
|
|
50
|
+
return raw as T;
|
|
74
51
|
}
|
|
75
52
|
|
|
76
|
-
/**
|
|
77
|
-
async function
|
|
53
|
+
/** The pending request gating a record, plus its snapshotted node config. */
|
|
54
|
+
async function pendingRequestFor(
|
|
78
55
|
engine: MinimalEngine,
|
|
79
56
|
objectName: string,
|
|
80
57
|
recordId: string,
|
|
81
|
-
): Promise<
|
|
58
|
+
): Promise<any | null> {
|
|
82
59
|
try {
|
|
83
60
|
const rows = await engine.find('sys_approval_request', {
|
|
84
61
|
where: { object_name: objectName, record_id: String(recordId), status: 'pending' },
|
|
85
62
|
limit: 1,
|
|
86
63
|
} as any);
|
|
87
|
-
return Array.isArray(rows) && rows
|
|
64
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
88
65
|
} catch {
|
|
89
|
-
return
|
|
66
|
+
return null;
|
|
90
67
|
}
|
|
91
68
|
}
|
|
92
69
|
|
|
93
70
|
/**
|
|
94
|
-
* Bind
|
|
95
|
-
*
|
|
71
|
+
* Bind the global record-lock hook. Caller is responsible for calling
|
|
72
|
+
* {@link unbindAllHooks} first if re-binding.
|
|
96
73
|
*/
|
|
97
|
-
export function
|
|
98
|
-
engine:
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
)
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
if (
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
engine
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
});
|
|
74
|
+
export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogger): void {
|
|
75
|
+
engine.registerHook('beforeUpdate', async (ctx: any) => {
|
|
76
|
+
const id = String((ctx?.input?.id ?? '') as string);
|
|
77
|
+
if (!id) return;
|
|
78
|
+
const object = (ctx?.object ?? ctx?.objectName) as string | undefined;
|
|
79
|
+
// No object name (shouldn't happen) or our own bookkeeping objects → skip.
|
|
80
|
+
if (!object || String(object).startsWith('sys_approval')) return;
|
|
81
|
+
|
|
82
|
+
const data = (ctx?.input?.data ?? {}) as Record<string, unknown>;
|
|
83
|
+
const changedFields = Object.keys(data).filter((k) => k !== 'id' && k !== 'updated_at');
|
|
84
|
+
if (changedFields.length === 0) return;
|
|
85
|
+
|
|
86
|
+
// Allow engine self-writes (status mirror from the approvals service, etc).
|
|
87
|
+
if ((ctx?.session as any)?.isSystem) return;
|
|
88
|
+
|
|
89
|
+
// Allow admin override.
|
|
90
|
+
const roles = (ctx?.session?.roles ?? []) as string[];
|
|
91
|
+
if (Array.isArray(roles) && roles.includes('admin')) return;
|
|
92
|
+
|
|
93
|
+
const pending = await pendingRequestFor(engine, object, id);
|
|
94
|
+
if (!pending) return;
|
|
95
|
+
|
|
96
|
+
const config = parseJson<any>(pending.node_config_json, {});
|
|
97
|
+
if (config?.lockRecord === false) return;
|
|
98
|
+
|
|
99
|
+
// Allow when every changed field is the approval status mirror.
|
|
100
|
+
const mirror = config?.approvalStatusField;
|
|
101
|
+
if (typeof mirror === 'string' && mirror && changedFields.every((f) => f === mirror)) return;
|
|
102
|
+
|
|
103
|
+
const err: any = new Error('RECORD_LOCKED: record is locked while an approval is in progress');
|
|
104
|
+
err.code = 'RECORD_LOCKED';
|
|
105
|
+
err.statusCode = 409;
|
|
106
|
+
throw err;
|
|
107
|
+
}, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
|
|
108
|
+
|
|
109
|
+
logger?.info?.('[approvals] record-lock hook bound');
|
|
195
110
|
}
|
|
196
111
|
|
|
197
|
-
/** Unregister every hook the
|
|
112
|
+
/** Unregister every hook the lock module registered. */
|
|
198
113
|
export function unbindAllHooks(engine: MinimalEngine): number {
|
|
199
114
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
200
115
|
}
|
|
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,46 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from 'vitest';
|
|
4
|
+
import { ApprovalsServicePlugin } from './approvals-plugin.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* ADR-0029 K2.b / D7 — the approvals plugin owns sys_approval_request /
|
|
8
|
+
* sys_approval_action and ships their Setup-app menu as a navigation
|
|
9
|
+
* contribution (rather than the entries living statically in the
|
|
10
|
+
* platform-objects Setup shell).
|
|
11
|
+
*/
|
|
12
|
+
describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', () => {
|
|
13
|
+
it('registers the approval objects and contributes the group_approvals slot', async () => {
|
|
14
|
+
const registered: any[] = [];
|
|
15
|
+
const ctx: any = {
|
|
16
|
+
getService: (name: string) =>
|
|
17
|
+
name === 'manifest' ? { register: (m: any) => registered.push(m) } : undefined,
|
|
18
|
+
logger: { info: () => {}, warn: () => {} },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const plugin = new ApprovalsServicePlugin({ disableService: true });
|
|
22
|
+
await plugin.init(ctx);
|
|
23
|
+
|
|
24
|
+
expect(registered).toHaveLength(1);
|
|
25
|
+
const manifest = registered[0];
|
|
26
|
+
|
|
27
|
+
// Owns both approval objects (moved out of platform-objects).
|
|
28
|
+
expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([
|
|
29
|
+
'sys_approval_action',
|
|
30
|
+
'sys_approval_request',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// Contributes its menu into the Setup app's approvals slot.
|
|
34
|
+
expect(manifest.navigationContributions).toHaveLength(1);
|
|
35
|
+
const contribution = manifest.navigationContributions[0];
|
|
36
|
+
expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
|
|
37
|
+
expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
|
|
38
|
+
'sys_approval_action',
|
|
39
|
+
'sys_approval_request',
|
|
40
|
+
]);
|
|
41
|
+
// Each entry is gated so the slot stays empty when the plugin is absent.
|
|
42
|
+
for (const item of contribution.items) {
|
|
43
|
+
expect(item.requiresObject).toBe(item.objectName);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|