@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.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,263 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import type { Plugin, PluginContext } from '@objectstack/core';
4
- import { SysApprovalRequest } from './sys-approval-request.object.js';
5
- import { SysApprovalAction } from './sys-approval-action.object.js';
6
- import { SysApprovalApprover } from './sys-approval-approver.object.js';
7
- import { SysApprovalToken } from './sys-approval-token.object.js';
8
- import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
9
- import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
10
- import {
11
- ApprovalService,
12
- ESCALATION_JOB_NAME,
13
- ESCALATION_SCAN_INTERVAL_MS,
14
- type ApprovalEngine,
15
- } from './approval-service.js';
16
- import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
17
- import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
18
-
19
- export interface ApprovalsPluginOptions {
20
- /** Disable runtime registration (schemas still register). */
21
- disableService?: boolean;
22
- /**
23
- * Interval between SLA escalation scans (ADR-0042). Defaults to
24
- * {@link ESCALATION_SCAN_INTERVAL_MS} (5 min). Only takes effect when a
25
- * `job` service is installed; without one, SLA stays display-only.
26
- */
27
- escalationScanIntervalMs?: number;
28
- /**
29
- * Absolute origin for actionable links in outbound notifications
30
- * (ADR-0043), e.g. `https://app.example.com`. Relative by default.
31
- */
32
- publicBaseUrl?: string;
33
- /**
34
- * Disable the record-lock hook. Schema + service stay intact; only the
35
- * engine-level lock wiring is suppressed. Useful when a caller wants the
36
- * manual API only (e.g. tests).
37
- */
38
- disableAutoHooks?: boolean;
39
- }
40
-
41
- /**
42
- * ApprovalsServicePlugin — registers sys_approval_{request,action}, the
43
- * `approvals` service, the `approval` flow node executor (ADR-0019), and the
44
- * record-lock hook.
45
- *
46
- * ADR-0019: approval is no longer a standalone process engine. A flow's
47
- * Approval node opens a request and suspends the run; a decision via the
48
- * service resumes it down the matching branch.
49
- */
50
- export class ApprovalsServicePlugin implements Plugin {
51
- name = 'com.objectstack.service.approvals';
52
- version = '1.0.0';
53
- type = 'standard';
54
- dependencies = ['com.objectstack.engine.objectql'];
55
-
56
- private readonly options: ApprovalsPluginOptions;
57
- private service?: ApprovalService;
58
- private engine?: any;
59
- private escalationJobScheduled = false;
60
-
61
- constructor(options: ApprovalsPluginOptions = {}) {
62
- this.options = options;
63
- }
64
-
65
- async init(ctx: PluginContext): Promise<void> {
66
- ctx.getService<{ register(m: any): void }>('manifest').register({
67
- id: 'com.objectstack.service.approvals',
68
- name: 'Approvals Service',
69
- version: '1.0.0',
70
- type: 'plugin',
71
- scope: 'system',
72
- defaultDatasource: 'cloud',
73
- namespace: 'sys',
74
- objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
75
- // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
76
- // `group_approvals` slot. This plugin owns these objects (K2.b), so it
77
- // ships their menu too; when the plugin isn't installed the slot is empty.
78
- navigationContributions: [
79
- {
80
- app: 'setup',
81
- group: 'group_approvals',
82
- priority: 100,
83
- items: [
84
- { id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
85
- { id: 'nav_approval_actions', type: 'object', label: 'Action History', objectName: 'sys_approval_action', icon: 'history', requiresObject: 'sys_approval_action' },
86
- { id: 'nav_approval_delegations', type: 'object', label: 'Delegations (OOO)', objectName: 'sys_approval_delegation', icon: 'user-clock', requiresObject: 'sys_approval_delegation' },
87
- ],
88
- },
89
- ],
90
- });
91
- // ADR-0029 D8 — contribute this plugin's object translations to the i18n
92
- // service on kernel:ready (the i18n plugin may register after this one).
93
- if (typeof (ctx as any).hook === 'function') {
94
- (ctx as any).hook('kernel:ready', async () => {
95
- try {
96
- const i18n = ctx.getService<any>('i18n');
97
- if (i18n && typeof i18n.loadTranslations === 'function') {
98
- const { ApprovalsTranslations } = await import('./translations/index.js');
99
- for (const [locale, data] of Object.entries(ApprovalsTranslations)) {
100
- i18n.loadTranslations(locale, data as Record<string, unknown>);
101
- }
102
- }
103
- } catch { /* i18n optional */ }
104
- });
105
- }
106
- ctx.logger.info('ApprovalsServicePlugin: schemas registered');
107
- }
108
-
109
- async start(ctx: PluginContext): Promise<void> {
110
- if (this.options.disableService) return;
111
- let engine: any = null;
112
- try { engine = ctx.getService<any>('objectql'); }
113
- catch { try { engine = ctx.getService<any>('data'); } catch { /* ignore */ } }
114
- if (!engine) {
115
- ctx.logger.warn('ApprovalsServicePlugin: no ObjectQL engine — service NOT registered');
116
- return;
117
- }
118
- this.engine = engine;
119
-
120
- this.service = new ApprovalService({
121
- engine: engine as ApprovalEngine,
122
- logger: ctx.logger,
123
- publicBaseUrl: this.options.publicBaseUrl,
124
- });
125
-
126
- // Record lock: block edits to a record while it has a pending request.
127
- // Delegation write-guard: a self-service OOO delegation may only name the
128
- // acting user as delegator (#1322 follow-up). Both bind under the same
129
- // package id, so unbindAllHooks clears them together.
130
- if (!this.options.disableAutoHooks) {
131
- try {
132
- unbindAllHooks(engine);
133
- bindApprovalLockHook(engine, ctx.logger);
134
- bindDelegationWriteGuard(engine, ctx.logger);
135
- } catch (err: any) {
136
- ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
137
- }
138
- }
139
-
140
- ctx.registerService('approvals', this.service);
141
- ctx.logger.info('ApprovalsServicePlugin: service registered');
142
-
143
- // Optional messaging service (ADR-0012): thread interactions (reassign /
144
- // remind / request-info / comment) notify users when present; without it
145
- // they degrade to audit-only.
146
- try {
147
- const messaging = ctx.getService<any>('messaging');
148
- if (messaging && typeof messaging.emit === 'function') {
149
- this.service.attachMessaging(messaging);
150
- }
151
- } catch { /* messaging not installed */ }
152
-
153
- // SLA escalation clock (ADR-0042): a plugin-internal job, deliberately
154
- // NOT a flow trigger (ADR-0041 §1). Interval sweep + one catch-up scan at
155
- // boot so a restart doesn't extend a breach by a scan period. Wired on
156
- // kernel:ready — the job service may start after this plugin. No `job`
157
- // service → SLA stays display-only.
158
- const wireEscalationClock = async () => {
159
- try {
160
- const jobs = ctx.getService<any>('job');
161
- if (!jobs || typeof jobs.schedule !== 'function' || !this.service) return;
162
- const svc = this.service;
163
- const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
164
- await jobs.schedule(ESCALATION_JOB_NAME, { type: 'interval', intervalMs }, async () => {
165
- await svc.runEscalations();
166
- });
167
- this.escalationJobScheduled = true;
168
- void svc.runEscalations().catch((err: any) => {
169
- ctx.logger.warn?.('[approvals] boot escalation sweep failed', { error: err?.message });
170
- });
171
- ctx.logger.info('ApprovalsServicePlugin: SLA escalation scan scheduled', { intervalMs });
172
- } catch { /* job service not installed */ }
173
- };
174
- // Actionable-link pages (ADR-0043): session-less confirm + redemption,
175
- // mounted straight on the host Hono app. GET only renders; the decision
176
- // happens exclusively on the POST (mail-gateway prefetch safe).
177
- const mountActionPages = async () => {
178
- try {
179
- const http = ctx.getService<any>('http-server');
180
- const rawApp = http && typeof http.getRawApp === 'function' ? http.getRawApp() : null;
181
- if (!rawApp || !this.service) return;
182
- const svc = this.service;
183
- const ACT_PATH = '/api/v1/approvals/act';
184
- const html = (c: any, body: string, status = 200) =>
185
- c.body(body, status, { 'Content-Type': 'text/html; charset=utf-8' });
186
- rawApp.get(ACT_PATH, async (c: any) => {
187
- const token = String(c.req.query('token') ?? '');
188
- const peek = await svc.peekActionToken(token);
189
- if (!peek.ok) return html(c, renderResultPage(peek.reason, peek.request), 200);
190
- return html(c, renderConfirmPage({
191
- request: peek.request, action: peek.action, approverId: peek.approverId,
192
- token, actPath: ACT_PATH,
193
- }));
194
- });
195
- rawApp.post(ACT_PATH, async (c: any) => {
196
- let token = '';
197
- try {
198
- const body = await c.req.parseBody();
199
- token = String(body?.token ?? '');
200
- } catch { /* fall through to invalid */ }
201
- const out = await svc.redeemActionToken(token);
202
- if (!out.ok) return html(c, renderResultPage(out.reason, out.request), 200);
203
- return html(c, renderResultPage(out.action === 'approve' ? 'approved' : 'rejected', out.request));
204
- });
205
- ctx.logger.info(`ApprovalsServicePlugin: actionable-link pages mounted at ${ACT_PATH}`);
206
- } catch { /* http server not installed */ }
207
- };
208
-
209
- // Pending-approver index backfill (issue #1745): rebuild the normalized
210
- // sys_approval_approver rows from the pending_approvers CSV so requests
211
- // written before the index existed (or drifted past a crashed sync) are
212
- // queryable. Idempotent; cost tracks the live pending queue.
213
- const backfillApproverIndex = async () => {
214
- try {
215
- const svc = this.service;
216
- if (!svc) return;
217
- const out = await svc.rebuildApproverIndex();
218
- if (out.inserted > 0 || out.deleted > 0) {
219
- ctx.logger.info('ApprovalsServicePlugin: approver index rebuilt', out);
220
- }
221
- } catch (err: any) {
222
- ctx.logger.warn?.('[approvals] approver index backfill failed', { error: err?.message });
223
- }
224
- };
225
-
226
- if (typeof (ctx as any).hook === 'function') {
227
- (ctx as any).hook('kernel:ready', wireEscalationClock);
228
- (ctx as any).hook('kernel:ready', mountActionPages);
229
- (ctx as any).hook('kernel:ready', backfillApproverIndex);
230
- } else {
231
- await wireEscalationClock();
232
- await mountActionPages();
233
- await backfillApproverIndex();
234
- }
235
-
236
- // ADR-0019: contribute the `approval` node to the flow engine when one is
237
- // present. The node lets a flow suspend on an approval and resume on
238
- // decision; the service is wired to the same engine so `decide()` can
239
- // resume the suspended run.
240
- try {
241
- const automation = ctx.getService<ApprovalAutomationSurface>('automation');
242
- if (automation && typeof automation.registerNodeExecutor === 'function') {
243
- this.service.attachAutomation(automation);
244
- registerApprovalNode(automation, this.service, ctx.logger);
245
- }
246
- } catch {
247
- ctx.logger.info('ApprovalsServicePlugin: no automation engine — approval node not registered');
248
- }
249
- }
250
-
251
- async stop(ctx: PluginContext): Promise<void> {
252
- if (this.escalationJobScheduled) {
253
- try {
254
- const jobs = ctx.getService<any>('job');
255
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
256
- } catch { /* ignore */ }
257
- this.escalationJobScheduled = false;
258
- }
259
- if (this.engine) {
260
- try { unbindAllHooks(this.engine); } catch { /* ignore */ }
261
- }
262
- }
263
- }
package/src/index.ts DELETED
@@ -1,39 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * @objectstack/plugin-approvals
5
- *
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.
11
- */
12
-
13
- export { SysApprovalRequest } from './sys-approval-request.object.js';
14
- export { SysApprovalAction } from './sys-approval-action.object.js';
15
- export { SysApprovalApprover } from './sys-approval-approver.object.js';
16
- export { SysApprovalDelegation } from './sys-approval-delegation.object.js';
17
- export {
18
- ApprovalService,
19
- type ApprovalEngine,
20
- type ApprovalClock,
21
- type ApprovalServiceOptions,
22
- type ApprovalResumeSurface,
23
- } from './approval-service.js';
24
- export {
25
- ApprovalsServicePlugin,
26
- type ApprovalsPluginOptions,
27
- } from './approvals-plugin.js';
28
- export {
29
- registerApprovalNode,
30
- type ApprovalAutomationSurface,
31
- } from './approval-node.js';
32
- export type {
33
- IApprovalService,
34
- ApprovalRequestRow,
35
- ApprovalActionRow,
36
- ApprovalDecisionInput,
37
- ApprovalDecisionResult,
38
- ApprovalStatus,
39
- } from '@objectstack/spec/contracts';
@@ -1,179 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- /**
4
- * Lifecycle Hooks — node-era record lock (ADR-0019).
5
- *
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.
10
- *
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:
13
- *
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.
24
- */
25
-
26
- export const APPROVALS_HOOK_PACKAGE = 'plugin-approvals:lock';
27
-
28
- interface MinimalEngine {
29
- registerHook(event: string, handler: (ctx: any) => any | Promise<any>, options?: {
30
- object?: string | string[];
31
- priority?: number;
32
- packageId?: string;
33
- }): void;
34
- unregisterHooksByPackage(packageId: string): number;
35
- find<T = any>(object: string, args: any, opts?: any): Promise<T[]>;
36
- }
37
-
38
- interface MinimalLogger {
39
- debug?: (msg: any, ...rest: any[]) => void;
40
- info?: (msg: any, ...rest: any[]) => void;
41
- warn?: (msg: any, ...rest: any[]) => void;
42
- error?: (msg: any, ...rest: any[]) => void;
43
- }
44
-
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; }
49
- }
50
- return raw as T;
51
- }
52
-
53
- /** The pending request gating a record, plus its snapshotted node config. */
54
- async function pendingRequestFor(
55
- engine: MinimalEngine,
56
- objectName: string,
57
- recordId: string,
58
- ): Promise<any | null> {
59
- try {
60
- const rows = await engine.find('sys_approval_request', {
61
- where: { object_name: objectName, record_id: String(recordId), status: 'pending' },
62
- limit: 1,
63
- } as any);
64
- return Array.isArray(rows) && rows[0] ? rows[0] : null;
65
- } catch {
66
- return null;
67
- }
68
- }
69
-
70
- /**
71
- * Bind the global record-lock hook. Caller is responsible for calling
72
- * {@link unbindAllHooks} first if re-binding.
73
- */
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');
110
- }
111
-
112
- /** The self-service out-of-office delegation object (#1322). */
113
- export const DELEGATION_OBJECT = 'sys_approval_delegation';
114
-
115
- /**
116
- * Self-service write guard for `sys_approval_delegation` (#1322 follow-up).
117
- *
118
- * The object is `apiEnabled` CRUD so a user can declare their own out-of-office
119
- * delegation. But it is a system object: it gets no auto `owner_id` anchor and
120
- * (with no `sharingModel`) defaults to a `public` sharing model, so an
121
- * unguarded member could **forge a delegation for someone else**
122
- * (`delegator_id = victim`) and reroute the victim's individually-routed
123
- * approvals to themselves. This guard forces a normal user's writes to name
124
- * themselves as the delegator:
125
- *
126
- * - **system** context (service / seed / import) → bypass;
127
- * - **admin** (`roles` includes `'admin'`) → may set `delegator_id` to anyone;
128
- * - otherwise `delegator_id` must equal the acting user — an absent delegator
129
- * on insert is stamped to the caller, a foreign delegator is rejected.
130
- *
131
- * Row-level ownership on update/delete (you can only touch a delegation you
132
- * created) is already enforced by `member_default`'s wildcard
133
- * `created_by == current_user.id` RLS; this guard adds the delegator-identity
134
- * check that RLS alone can't express. Mirrors the ADR-0092 identity write-guard
135
- * shape and the security plugin's `owner_id` anchor guard, scoped to this one
136
- * object.
137
- */
138
- export function bindDelegationWriteGuard(engine: MinimalEngine, logger?: MinimalLogger): void {
139
- const makeGuard = (isInsert: boolean) => async (ctx: any) => {
140
- const session = (ctx?.session ?? {}) as any;
141
- if (session.isSystem) return; // service / seed / import
142
- const roles = (session.roles ?? []) as unknown[];
143
- if (Array.isArray(roles) && roles.includes('admin')) return; // admin may act for anyone
144
- const userId = session.userId != null ? String(session.userId) : '';
145
- const data = ctx?.input?.data;
146
- const rows = Array.isArray(data) ? data : (data && typeof data === 'object' ? [data] : []);
147
- const deny = (): never => {
148
- const err: any = new Error(
149
- 'FORBIDDEN: you may only manage out-of-office delegations where you are the delegator'
150
- + (userId ? ` ('${userId}')` : ''),
151
- );
152
- err.code = 'FORBIDDEN';
153
- err.statusCode = 403;
154
- throw err;
155
- };
156
- for (const row of rows) {
157
- if (!row || typeof row !== 'object' || Array.isArray(row)) continue;
158
- const has = Object.prototype.hasOwnProperty.call(row, 'delegator_id');
159
- const supplied = has ? String((row as any).delegator_id ?? '') : '';
160
- if (isInsert && (!has || supplied === '')) {
161
- // Self-service: stamp the caller as delegator when omitted (the schema's
162
- // `required` is the fallback if the engine doesn't persist the stamp).
163
- if (!userId) deny();
164
- (row as any).delegator_id = userId;
165
- continue;
166
- }
167
- // A foreign delegator on insert (forge) or update (relabel/hijack) → deny.
168
- if (has && supplied !== userId) deny();
169
- }
170
- };
171
- engine.registerHook('beforeInsert', makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
172
- engine.registerHook('beforeUpdate', makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
173
- logger?.info?.('[approvals] delegation write-guard bound');
174
- }
175
-
176
- /** Unregister every hook the lock module registered. */
177
- export function unbindAllHooks(engine: MinimalEngine): number {
178
- return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
179
- }
@@ -1,50 +0,0 @@
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 the 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_approver',
31
- 'sys_approval_delegation',
32
- 'sys_approval_request',
33
- 'sys_approval_token',
34
- ]);
35
-
36
- // Contributes its menu into the Setup app's approvals slot.
37
- expect(manifest.navigationContributions).toHaveLength(1);
38
- const contribution = manifest.navigationContributions[0];
39
- expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
40
- expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
41
- 'sys_approval_action',
42
- 'sys_approval_delegation',
43
- 'sys_approval_request',
44
- ]);
45
- // Each entry is gated so the slot stays empty when the plugin is absent.
46
- for (const item of contribution.items) {
47
- expect(item.requiresObject).toBe(item.objectName);
48
- }
49
- });
50
- });
@@ -1,140 +0,0 @@
1
- // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
-
3
- import { ObjectSchema, Field } from '@objectstack/spec/data';
4
-
5
- /**
6
- * sys_approval_action — Audit trail row per approval action.
7
- *
8
- * Append-only: every `submit`, `approve`, `reject`, `recall`, or
9
- * `escalate` event lands here. The engine reads back per-step approval
10
- * rows to evaluate `behavior: 'unanimous'` (all approvers must approve
11
- * before advancing) versus `first_response` (any single approval
12
- * advances the step).
13
- *
14
- * @namespace sys
15
- */
16
- export const SysApprovalAction = ObjectSchema.create({
17
- name: 'sys_approval_action',
18
- label: 'Approval Action',
19
- pluralLabel: 'Approval Actions',
20
- icon: 'check-circle',
21
- isSystem: true,
22
- managedBy: 'append-only',
23
- description: 'Append-only audit trail for approval actions',
24
- displayNameField: 'id',
25
- nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
26
- titleFormat: '{action} · {step_name}',
27
- highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'created_at'],
28
-
29
- listViews: {
30
- recent: {
31
- type: 'grid',
32
- name: 'recent',
33
- label: 'Recent',
34
- data: { provider: 'object', object: 'sys_approval_action' },
35
- columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'],
36
- sort: [{ field: 'created_at', order: 'desc' }],
37
- pagination: { pageSize: 50 },
38
- emptyState: { title: 'No approval actions yet', message: 'Actions are logged automatically when approvals progress.' },
39
- },
40
- by_actor: {
41
- type: 'grid',
42
- name: 'by_actor',
43
- label: 'By Actor',
44
- data: { provider: 'object', object: 'sys_approval_action' },
45
- columns: ['actor_id', 'created_at', 'request_id', 'step_name', 'action'],
46
- sort: [{ field: 'actor_id', order: 'asc' }, { field: 'created_at', order: 'desc' }],
47
- grouping: { fields: [{ field: 'actor_id', order: 'asc', collapsed: false }] },
48
- pagination: { pageSize: 100 },
49
- },
50
- all_actions: {
51
- type: 'grid',
52
- name: 'all_actions',
53
- label: 'All',
54
- data: { provider: 'object', object: 'sys_approval_action' },
55
- columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'],
56
- sort: [{ field: 'created_at', order: 'desc' }],
57
- pagination: { pageSize: 100 },
58
- },
59
- },
60
-
61
- fields: {
62
- id: Field.text({ label: 'Action ID', required: true, readonly: true, group: 'System' }),
63
-
64
- organization_id: Field.lookup('sys_organization', {
65
- label: 'Organization',
66
- required: false,
67
- group: 'System',
68
- description: 'Tenant that owns this action (mirrors the parent request)',
69
- }),
70
-
71
- request_id: Field.lookup('sys_approval_request', {
72
- label: 'Request',
73
- required: true,
74
- group: 'Target',
75
- }),
76
-
77
- step_name: Field.text({
78
- label: 'Step',
79
- required: false,
80
- maxLength: 100,
81
- description: 'Machine name of the step at the time of the action',
82
- group: 'Target',
83
- }),
84
-
85
- step_index: Field.number({
86
- label: 'Step Index',
87
- required: false,
88
- group: 'Target',
89
- }),
90
-
91
- action: Field.select(
92
- // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
93
- // remind / request_info / comment are thread interactions — they never
94
- // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
95
- // revision and the later resubmission. ooo_substitute (#1322 M1) is a
96
- // system-recorded reroute of an out-of-office approver — no flow movement.
97
- ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit', 'ooo_substitute'],
98
- {
99
- label: 'Action',
100
- required: true,
101
- group: 'Action',
102
- },
103
- ),
104
-
105
- actor_id: Field.lookup('sys_user', {
106
- label: 'Actor',
107
- required: false,
108
- group: 'Action',
109
- }),
110
-
111
- comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }),
112
-
113
- attachments: Field.file({
114
- label: 'Attachments',
115
- required: false,
116
- multiple: true,
117
- group: 'Action',
118
- description: 'Files supporting this action — e.g. a signed contract or evidence (#3266).',
119
- }),
120
-
121
- created_at: Field.datetime({
122
- label: 'Created At',
123
- required: true,
124
- defaultValue: 'NOW()',
125
- readonly: true,
126
- group: 'System',
127
- }),
128
- },
129
-
130
- indexes: [
131
- { fields: ['request_id', 'created_at'] },
132
- { fields: ['request_id', 'step_index', 'action'] },
133
- ],
134
-
135
- enable: {
136
- // [ADR-0103] Engine-owned append-only decision log: appended by the approval
137
- // engine (SYSTEM_CTX). Reads stay open.
138
- apiMethods: ['get', 'list'],
139
- },
140
- });