@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.
@@ -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
- SysApprovalProcess,
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 { bindProcessHooks, unbindAllHooks } from './lifecycle-hooks.js';
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 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).
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_{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.
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: [SysApprovalProcess, SysApprovalRequest, SysApprovalAction],
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
- if (metadataRepo) {
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
- // Re-bind hooks on every registry mutation.
91
- this.service.setRegistryChangeHandler(() => this.rebindHooks());
92
- // Initial bind happens once the kernel is ready so the AppPlugin's
93
- // declarative process seeder has already populated sys_approval_process.
94
- const hookOn = (ctx as any).hook ?? (ctx as any).on;
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
- private async rebindHooks(): Promise<void> {
112
- if (!this.engine || !this.service) return;
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
- unbindAllHooks(this.engine);
115
- const processes = await this.service.listProcesses({ activeOnly: true }, { isSystem: true, roles: [], permissions: [] } as any);
116
- bindProcessHooks(this.engine, this.service, processes, this.logger);
117
- } catch (err: any) {
118
- this.logger?.warn?.('[approvals] rebindHooks failed', { error: err?.message });
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
- * 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.
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
- SysApprovalProcess,
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';
@@ -1,32 +1,29 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  /**
4
- * Lifecycle Hooks — Phase B auto-takeover.
4
+ * Lifecycle Hooks — node-era record lock (ADR-0019).
5
5
  *
6
- * For each active ApprovalProcess we bind three hooks on its target object:
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
- * 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).
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
- * All hooks are registered with `packageId: 'plugin-approvals:auto'` so
18
- * that re-bind on `defineProcess`/`deleteProcess` can call
19
- * `engine.unregisterHooksByPackage(...)` first.
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
- 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;
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
- * 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;
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
- 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);
50
+ return raw as T;
74
51
  }
75
52
 
76
- /** Does this record already have a pending approval request? */
77
- async function hasPendingRequest(
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<boolean> {
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.length > 0;
64
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
88
65
  } catch {
89
- return false;
66
+ return null;
90
67
  }
91
68
  }
92
69
 
93
70
  /**
94
- * Bind auto-trigger + lock hooks for the supplied active processes.
95
- * Caller is responsible for calling `unbindAll` first if re-binding.
71
+ * Bind the global record-lock hook. Caller is responsible for calling
72
+ * {@link unbindAllHooks} first if re-binding.
96
73
  */
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
- });
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 auto-trigger module ever registered. */
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
+ });