@objectstack/plugin-approvals 15.1.0 → 16.0.0-rc.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 +115 -0
- package/dist/index.d.mts +2999 -2410
- package/dist/index.d.ts +2999 -2410
- package/dist/index.js +849 -94
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +850 -95
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/scripts/i18n-extract.config.ts +2 -1
- package/src/approval-service.test.ts +437 -1
- package/src/approval-service.ts +403 -58
- package/src/approvals-plugin.ts +9 -3
- package/src/index.ts +1 -0
- package/src/lifecycle-hooks.ts +64 -0
- package/src/nav-contribution.test.ts +2 -0
- package/src/sys-approval-action.object.ts +17 -2
- package/src/sys-approval-approver.object.ts +6 -0
- package/src/sys-approval-delegation.object.ts +142 -0
- package/src/sys-approval-request.object.test.ts +94 -0
- package/src/sys-approval-request.object.ts +160 -0
- package/src/sys-approval-token.object.ts +6 -0
- package/src/translations/en.objects.generated.ts +49 -0
- package/src/translations/es-ES.objects.generated.ts +49 -0
- package/src/translations/ja-JP.objects.generated.ts +49 -0
- package/src/translations/zh-CN.objects.generated.ts +49 -0
package/src/approvals-plugin.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
|
5
5
|
import { SysApprovalAction } from './sys-approval-action.object.js';
|
|
6
6
|
import { SysApprovalApprover } from './sys-approval-approver.object.js';
|
|
7
7
|
import { SysApprovalToken } from './sys-approval-token.object.js';
|
|
8
|
+
import { SysApprovalDelegation } from './sys-approval-delegation.object.js';
|
|
8
9
|
import { renderConfirmPage, renderResultPage } from './action-link-pages.js';
|
|
9
10
|
import {
|
|
10
11
|
ApprovalService,
|
|
@@ -12,7 +13,7 @@ import {
|
|
|
12
13
|
ESCALATION_SCAN_INTERVAL_MS,
|
|
13
14
|
type ApprovalEngine,
|
|
14
15
|
} from './approval-service.js';
|
|
15
|
-
import { bindApprovalLockHook, unbindAllHooks } from './lifecycle-hooks.js';
|
|
16
|
+
import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
|
|
16
17
|
import { registerApprovalNode, type ApprovalAutomationSurface } from './approval-node.js';
|
|
17
18
|
|
|
18
19
|
export interface ApprovalsPluginOptions {
|
|
@@ -70,7 +71,7 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
70
71
|
scope: 'system',
|
|
71
72
|
defaultDatasource: 'cloud',
|
|
72
73
|
namespace: 'sys',
|
|
73
|
-
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
|
|
74
|
+
objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
|
|
74
75
|
// ADR-0029 D7 — contribute the Approvals entries into the Setup app's
|
|
75
76
|
// `group_approvals` slot. This plugin owns these objects (K2.b), so it
|
|
76
77
|
// ships their menu too; when the plugin isn't installed the slot is empty.
|
|
@@ -82,6 +83,7 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
82
83
|
items: [
|
|
83
84
|
{ id: 'nav_approval_requests', type: 'object', label: 'Requests', objectName: 'sys_approval_request', icon: 'inbox', requiresObject: 'sys_approval_request' },
|
|
84
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' },
|
|
85
87
|
],
|
|
86
88
|
},
|
|
87
89
|
],
|
|
@@ -122,12 +124,16 @@ export class ApprovalsServicePlugin implements Plugin {
|
|
|
122
124
|
});
|
|
123
125
|
|
|
124
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.
|
|
125
130
|
if (!this.options.disableAutoHooks) {
|
|
126
131
|
try {
|
|
127
132
|
unbindAllHooks(engine);
|
|
128
133
|
bindApprovalLockHook(engine, ctx.logger);
|
|
134
|
+
bindDelegationWriteGuard(engine, ctx.logger);
|
|
129
135
|
} catch (err: any) {
|
|
130
|
-
ctx.logger.warn?.('[approvals] failed to bind
|
|
136
|
+
ctx.logger.warn?.('[approvals] failed to bind approval hooks', { error: err?.message });
|
|
131
137
|
}
|
|
132
138
|
}
|
|
133
139
|
|
package/src/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
export { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
14
14
|
export { SysApprovalAction } from './sys-approval-action.object.js';
|
|
15
15
|
export { SysApprovalApprover } from './sys-approval-approver.object.js';
|
|
16
|
+
export { SysApprovalDelegation } from './sys-approval-delegation.object.js';
|
|
16
17
|
export {
|
|
17
18
|
ApprovalService,
|
|
18
19
|
type ApprovalEngine,
|
package/src/lifecycle-hooks.ts
CHANGED
|
@@ -109,6 +109,70 @@ export function bindApprovalLockHook(engine: MinimalEngine, logger?: MinimalLogg
|
|
|
109
109
|
logger?.info?.('[approvals] record-lock hook bound');
|
|
110
110
|
}
|
|
111
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
|
+
|
|
112
176
|
/** Unregister every hook the lock module registered. */
|
|
113
177
|
export function unbindAllHooks(engine: MinimalEngine): number {
|
|
114
178
|
return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
|
|
@@ -28,6 +28,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
|
|
|
28
28
|
expect(manifest.objects.map((o: any) => o.name).sort()).toEqual([
|
|
29
29
|
'sys_approval_action',
|
|
30
30
|
'sys_approval_approver',
|
|
31
|
+
'sys_approval_delegation',
|
|
31
32
|
'sys_approval_request',
|
|
32
33
|
'sys_approval_token',
|
|
33
34
|
]);
|
|
@@ -38,6 +39,7 @@ describe('ApprovalsServicePlugin schema + nav contribution (ADR-0029 K2.b)', ()
|
|
|
38
39
|
expect(contribution).toMatchObject({ app: 'setup', group: 'group_approvals' });
|
|
39
40
|
expect(contribution.items.map((i: any) => i.objectName).sort()).toEqual([
|
|
40
41
|
'sys_approval_action',
|
|
42
|
+
'sys_approval_delegation',
|
|
41
43
|
'sys_approval_request',
|
|
42
44
|
]);
|
|
43
45
|
// Each entry is gated so the slot stays empty when the plugin is absent.
|
|
@@ -92,8 +92,9 @@ export const SysApprovalAction = ObjectSchema.create({
|
|
|
92
92
|
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
|
|
93
93
|
// remind / request_info / comment are thread interactions — they never
|
|
94
94
|
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
|
|
95
|
-
// revision and the later resubmission.
|
|
96
|
-
|
|
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'],
|
|
97
98
|
{
|
|
98
99
|
label: 'Action',
|
|
99
100
|
required: true,
|
|
@@ -109,6 +110,14 @@ export const SysApprovalAction = ObjectSchema.create({
|
|
|
109
110
|
|
|
110
111
|
comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }),
|
|
111
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
|
+
|
|
112
121
|
created_at: Field.datetime({
|
|
113
122
|
label: 'Created At',
|
|
114
123
|
required: true,
|
|
@@ -122,4 +131,10 @@ export const SysApprovalAction = ObjectSchema.create({
|
|
|
122
131
|
{ fields: ['request_id', 'created_at'] },
|
|
123
132
|
{ fields: ['request_id', 'step_index', 'action'] },
|
|
124
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
|
+
},
|
|
125
140
|
});
|
|
@@ -76,4 +76,10 @@ export const SysApprovalApprover = ObjectSchema.create({
|
|
|
76
76
|
// Sync path: rewrite all rows of one request on each approver-set change.
|
|
77
77
|
{ fields: ['request_id'] },
|
|
78
78
|
],
|
|
79
|
+
|
|
80
|
+
enable: {
|
|
81
|
+
// [ADR-0103] Engine-owned: approver rows are rewritten by the approval
|
|
82
|
+
// engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
|
|
83
|
+
apiMethods: ['get', 'list'],
|
|
84
|
+
},
|
|
79
85
|
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { ObjectSchema, Field } from '@objectstack/spec/data';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* sys_approval_delegation — self-service out-of-office (OOO) delegation (#1322 M1).
|
|
7
|
+
*
|
|
8
|
+
* A standing, self-declared rule: "while I (the delegator) am out between
|
|
9
|
+
* `valid_from` and `valid_until`, route the approver slots that would resolve
|
|
10
|
+
* to me onto my delegate instead." The approval service consults active rows
|
|
11
|
+
* in `ApprovalService.expandApprovers` when resolving an approval node's
|
|
12
|
+
* INDIVIDUALLY-routed approvers (`type: user` / `field` / `manager`) — the
|
|
13
|
+
* delegate becomes a real pending approver and acts under their own identity,
|
|
14
|
+
* so nothing is impersonated and the audit trail stays honest.
|
|
15
|
+
*
|
|
16
|
+
* Modelled as its own object (not a scalar on the better-auth-locked
|
|
17
|
+
* `sys_user`), mirroring the `sys_user_position` delegation precedent
|
|
18
|
+
* (ADR-0091): the validity window is enforced at RESOLUTION time via the
|
|
19
|
+
* shared `isGrantActive` predicate — never by a background job (ADR-0049).
|
|
20
|
+
* The window is half-open `[valid_from, valid_until)` in UTC.
|
|
21
|
+
*
|
|
22
|
+
* Scope note: this is the community-core OOO auto-skip only. Long-term proxy
|
|
23
|
+
* "act-as" access (viewing/acting on another user's full queue under their
|
|
24
|
+
* authority), delegation governance / segregation-of-duties, and org-wide
|
|
25
|
+
* administration of others' delegations are enterprise concerns tracked
|
|
26
|
+
* separately (objectstack-ai/cloud#855), not here.
|
|
27
|
+
*
|
|
28
|
+
* @namespace sys
|
|
29
|
+
*/
|
|
30
|
+
export const SysApprovalDelegation = ObjectSchema.create({
|
|
31
|
+
name: 'sys_approval_delegation',
|
|
32
|
+
label: 'Approval Delegation',
|
|
33
|
+
pluralLabel: 'Approval Delegations',
|
|
34
|
+
icon: 'user-clock',
|
|
35
|
+
isSystem: true,
|
|
36
|
+
managedBy: 'system',
|
|
37
|
+
// [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
|
|
38
|
+
// authors their own out-of-office delegation. Affordance only (matches the
|
|
39
|
+
// full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
|
|
40
|
+
// keeps the system write guard from rejecting the self-service write.
|
|
41
|
+
userActions: { create: true, edit: true, delete: true },
|
|
42
|
+
description:
|
|
43
|
+
'Self-service out-of-office rule: route this user\'s approver slots to a delegate within a time window (#1322 M1).',
|
|
44
|
+
titleFormat: '{delegator_id} → {delegate_id}',
|
|
45
|
+
highlightFields: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until'],
|
|
46
|
+
|
|
47
|
+
listViews: {
|
|
48
|
+
active: {
|
|
49
|
+
type: 'grid',
|
|
50
|
+
name: 'active',
|
|
51
|
+
label: 'Active',
|
|
52
|
+
data: { provider: 'object', object: 'sys_approval_delegation' },
|
|
53
|
+
columns: ['delegator_id', 'delegate_id', 'valid_from', 'valid_until', 'reason'],
|
|
54
|
+
sort: [{ field: 'valid_until', order: 'asc' }],
|
|
55
|
+
pagination: { pageSize: 50 },
|
|
56
|
+
emptyState: {
|
|
57
|
+
title: 'No delegations',
|
|
58
|
+
message: 'Declare an out-of-office delegation so approvals route to a backup while you are away.',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
|
|
63
|
+
fields: {
|
|
64
|
+
id: Field.text({ label: 'Delegation ID', required: true, readonly: true, group: 'System' }),
|
|
65
|
+
|
|
66
|
+
delegator_id: Field.lookup('sys_user', {
|
|
67
|
+
label: 'Delegator',
|
|
68
|
+
required: true,
|
|
69
|
+
group: 'Delegation',
|
|
70
|
+
description: 'The user going out of office; their individually-routed approver slots are rerouted while active.',
|
|
71
|
+
}),
|
|
72
|
+
|
|
73
|
+
delegate_id: Field.lookup('sys_user', {
|
|
74
|
+
label: 'Delegate',
|
|
75
|
+
required: true,
|
|
76
|
+
group: 'Delegation',
|
|
77
|
+
description: 'The backup who receives the delegator\'s approvals while this rule is active. Acts under their own identity.',
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
valid_from: Field.datetime({
|
|
81
|
+
label: 'Valid From',
|
|
82
|
+
required: false,
|
|
83
|
+
group: 'Delegation',
|
|
84
|
+
description:
|
|
85
|
+
'Rule is inactive before this instant. Null = active immediately. ' +
|
|
86
|
+
'Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job.',
|
|
87
|
+
}),
|
|
88
|
+
|
|
89
|
+
valid_until: Field.datetime({
|
|
90
|
+
label: 'Valid Until',
|
|
91
|
+
required: false,
|
|
92
|
+
group: 'Delegation',
|
|
93
|
+
description:
|
|
94
|
+
'Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires.',
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
reason: Field.text({
|
|
98
|
+
label: 'Reason',
|
|
99
|
+
required: false,
|
|
100
|
+
maxLength: 500,
|
|
101
|
+
group: 'Delegation',
|
|
102
|
+
description: 'Why the delegation exists (e.g. "Annual leave 5/26–5/30"). Recorded on the substitution audit row.',
|
|
103
|
+
}),
|
|
104
|
+
|
|
105
|
+
organization_id: Field.lookup('sys_organization', {
|
|
106
|
+
label: 'Organization',
|
|
107
|
+
required: false,
|
|
108
|
+
group: 'System',
|
|
109
|
+
description: 'Tenant that owns this rule; null = applies across tenants for this delegator.',
|
|
110
|
+
}),
|
|
111
|
+
|
|
112
|
+
created_at: Field.datetime({
|
|
113
|
+
label: 'Created At',
|
|
114
|
+
defaultValue: 'NOW()',
|
|
115
|
+
readonly: true,
|
|
116
|
+
group: 'System',
|
|
117
|
+
}),
|
|
118
|
+
|
|
119
|
+
updated_at: Field.datetime({
|
|
120
|
+
label: 'Updated At',
|
|
121
|
+
defaultValue: 'NOW()',
|
|
122
|
+
readonly: true,
|
|
123
|
+
group: 'System',
|
|
124
|
+
}),
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
indexes: [
|
|
128
|
+
// Resolution-time lookup: "active delegations for this delegator".
|
|
129
|
+
{ fields: ['delegator_id', 'organization_id'] },
|
|
130
|
+
{ fields: ['delegate_id'] },
|
|
131
|
+
{ fields: ['valid_until'] },
|
|
132
|
+
],
|
|
133
|
+
|
|
134
|
+
enable: {
|
|
135
|
+
trackHistory: true,
|
|
136
|
+
searchable: true,
|
|
137
|
+
apiEnabled: true,
|
|
138
|
+
apiMethods: ['get', 'list', 'create', 'update', 'delete'],
|
|
139
|
+
trash: true,
|
|
140
|
+
mru: false,
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Contract test for the server-declared decision actions on
|
|
5
|
+
* `sys_approval_request` (objectui#2678 P2-4 / retire-hardcoded-buttons).
|
|
6
|
+
*
|
|
7
|
+
* The console's generic action runtime renders + executes these wherever the
|
|
8
|
+
* object is surfaced (the approvals inbox included), so the inbox no longer
|
|
9
|
+
* hand-writes a button per capability. That only holds if the declared set
|
|
10
|
+
* stays faithful to the REST routes it targets and to the who-can-act gating.
|
|
11
|
+
* This pins both:
|
|
12
|
+
*
|
|
13
|
+
* • every `type:'api'` target resolves `{id}` and points at a route that the
|
|
14
|
+
* REST server actually registers (approve/reject/reassign/recall/remind/
|
|
15
|
+
* request-info/revise/resubmit) — a typo'd verb would 404 silently in the UI;
|
|
16
|
+
* • submitter-only levers (remind/recall/resubmit) gate on
|
|
17
|
+
* `submitter_id == ctx.user.id` so a non-submitter never sees them.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from 'vitest';
|
|
21
|
+
import { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
22
|
+
|
|
23
|
+
const actions = (SysApprovalRequest as any).actions as any[];
|
|
24
|
+
const byName = (n: string) => actions.find((a) => a.name === n);
|
|
25
|
+
/** `ObjectSchema.create` normalizes `visible` strings into a
|
|
26
|
+
* `{ dialect: 'cel', source }` envelope — read the source for substring asserts. */
|
|
27
|
+
const vis = (n: string): string => {
|
|
28
|
+
const v = byName(n).visible;
|
|
29
|
+
return typeof v === 'string' ? v : String(v?.source ?? '');
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Verbs the REST server registers under `/api/v1/approvals/requests/:id/*`. */
|
|
33
|
+
const ROUTE_VERBS = new Set([
|
|
34
|
+
'approve', 'reject', 'reassign', 'recall', 'remind', 'request-info', 'revise', 'resubmit',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
describe('sys_approval_request declared actions', () => {
|
|
38
|
+
it('declares the full decision + continuity set', () => {
|
|
39
|
+
expect(actions.map((a) => a.name).sort()).toEqual(
|
|
40
|
+
[
|
|
41
|
+
'approval_approve',
|
|
42
|
+
'approval_recall',
|
|
43
|
+
'approval_reassign',
|
|
44
|
+
'approval_reject',
|
|
45
|
+
'approval_remind',
|
|
46
|
+
'approval_request_info',
|
|
47
|
+
'approval_resubmit',
|
|
48
|
+
'approval_send_back',
|
|
49
|
+
].sort(),
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('every api target points at a registered approvals route verb and injects {id}', () => {
|
|
54
|
+
for (const a of actions) {
|
|
55
|
+
expect(a.type).toBe('api');
|
|
56
|
+
expect(a.method).toBe('POST');
|
|
57
|
+
const m = /^\/api\/v1\/approvals\/requests\/\{id\}\/([a-z-]+)$/.exec(a.target);
|
|
58
|
+
expect(m, `${a.name} target ${a.target}`).not.toBeNull();
|
|
59
|
+
expect(ROUTE_VERBS.has(m![1]), `${a.name} → ${m![1]}`).toBe(true);
|
|
60
|
+
expect(a.refreshAfter).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('gates submitter-only levers on the current user; approver actions only on pending', () => {
|
|
65
|
+
for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
|
|
66
|
+
expect(vis(name)).toContain('record.submitter_id == ctx.user.id');
|
|
67
|
+
}
|
|
68
|
+
// Approver-side actions defer who-can-act to the service; they only trim the
|
|
69
|
+
// non-pending case in the UI.
|
|
70
|
+
for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
|
|
71
|
+
expect(vis(name)).toContain('record.status == "pending"');
|
|
72
|
+
expect(vis(name)).not.toContain('ctx.user.id');
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
|
|
77
|
+
expect(vis('approval_recall')).toContain('record.status == "returned"');
|
|
78
|
+
expect(byName('approval_recall').confirmText).toBeTruthy();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('reassign collects the new approver via a field-backed sys_user picker keyed as `to`', () => {
|
|
82
|
+
const toParam = byName('approval_reassign').params.find((p: any) => p.name === 'to');
|
|
83
|
+
expect(toParam).toMatchObject({ field: 'submitter_id', name: 'to', required: true });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('request-info requires a comment; other params stay optional', () => {
|
|
87
|
+
const ri = byName('approval_request_info').params.find((p: any) => p.name === 'comment');
|
|
88
|
+
expect(ri.required).toBe(true);
|
|
89
|
+
for (const name of ['approval_send_back', 'approval_remind', 'approval_recall', 'approval_resubmit']) {
|
|
90
|
+
const c = byName(name).params.find((p: any) => p.name === 'comment');
|
|
91
|
+
expect(c.required ?? false).toBe(false);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -229,4 +229,164 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
229
229
|
{ fields: ['status', 'updated_at'] },
|
|
230
230
|
{ fields: ['submitter_id', 'status'] },
|
|
231
231
|
],
|
|
232
|
+
|
|
233
|
+
// Server-declared decision actions (objectui#2678 P2-4). The console's
|
|
234
|
+
// generic action runtime renders and executes these wherever this object is
|
|
235
|
+
// surfaced — the approvals inbox included — so new decision capabilities
|
|
236
|
+
// (and their params) ship as metadata, not as hand-written buttons. Each
|
|
237
|
+
// targets the existing approvals REST route; `{id}` resolves from the row
|
|
238
|
+
// and `actorId` defaults to the caller server-side. The service remains the
|
|
239
|
+
// authority on who may act (pending-approver check) — `visible` only trims
|
|
240
|
+
// the obvious non-pending case.
|
|
241
|
+
actions: [
|
|
242
|
+
{
|
|
243
|
+
name: 'approval_approve',
|
|
244
|
+
label: 'Approve',
|
|
245
|
+
icon: 'check-circle',
|
|
246
|
+
type: 'api',
|
|
247
|
+
method: 'POST',
|
|
248
|
+
target: '/api/v1/approvals/requests/{id}/approve',
|
|
249
|
+
params: [
|
|
250
|
+
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
251
|
+
],
|
|
252
|
+
visible: 'record.status == "pending"',
|
|
253
|
+
locations: ['record_section', 'list_item'],
|
|
254
|
+
successMessage: 'Approved.',
|
|
255
|
+
refreshAfter: true,
|
|
256
|
+
},
|
|
257
|
+
{
|
|
258
|
+
name: 'approval_reject',
|
|
259
|
+
label: 'Reject',
|
|
260
|
+
icon: 'x-circle',
|
|
261
|
+
type: 'api',
|
|
262
|
+
method: 'POST',
|
|
263
|
+
target: '/api/v1/approvals/requests/{id}/reject',
|
|
264
|
+
params: [
|
|
265
|
+
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
266
|
+
],
|
|
267
|
+
visible: 'record.status == "pending"',
|
|
268
|
+
confirmText: 'Reject this request? A rejection is final for every approver.',
|
|
269
|
+
locations: ['record_section', 'list_item'],
|
|
270
|
+
successMessage: 'Rejected.',
|
|
271
|
+
refreshAfter: true,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
name: 'approval_reassign',
|
|
275
|
+
label: 'Reassign',
|
|
276
|
+
icon: 'arrow-right-left',
|
|
277
|
+
type: 'api',
|
|
278
|
+
method: 'POST',
|
|
279
|
+
target: '/api/v1/approvals/requests/{id}/reassign',
|
|
280
|
+
params: [
|
|
281
|
+
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
|
|
282
|
+
// the console resolves its lookup config (`reference_to: sys_user`) so the
|
|
283
|
+
// dialog renders a real user picker, while `name: 'to'` overrides the
|
|
284
|
+
// request-body key to the `to` the reassign route expects. This is a
|
|
285
|
+
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
|
|
286
|
+
{ field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
|
|
287
|
+
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
288
|
+
],
|
|
289
|
+
visible: 'record.status == "pending"',
|
|
290
|
+
locations: ['record_section'],
|
|
291
|
+
successMessage: 'Reassigned.',
|
|
292
|
+
refreshAfter: true,
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
// ── Approver secondary decisions ────────────────────────────────
|
|
296
|
+
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
297
|
+
// actions on a pending request; the service is the authority on who may act,
|
|
298
|
+
// so `visible` only trims the non-pending case (matching approve/reject).
|
|
299
|
+
{
|
|
300
|
+
name: 'approval_send_back',
|
|
301
|
+
label: 'Send back',
|
|
302
|
+
icon: 'corner-up-left',
|
|
303
|
+
type: 'api',
|
|
304
|
+
method: 'POST',
|
|
305
|
+
target: '/api/v1/approvals/requests/{id}/revise',
|
|
306
|
+
params: [
|
|
307
|
+
{ name: 'comment', label: 'Reason', type: 'textarea', required: false },
|
|
308
|
+
],
|
|
309
|
+
visible: 'record.status == "pending"',
|
|
310
|
+
locations: ['record_section'],
|
|
311
|
+
successMessage: 'Sent back for revision.',
|
|
312
|
+
refreshAfter: true,
|
|
313
|
+
},
|
|
314
|
+
{
|
|
315
|
+
name: 'approval_request_info',
|
|
316
|
+
label: 'Request info',
|
|
317
|
+
icon: 'help-circle',
|
|
318
|
+
type: 'api',
|
|
319
|
+
method: 'POST',
|
|
320
|
+
target: '/api/v1/approvals/requests/{id}/request-info',
|
|
321
|
+
params: [
|
|
322
|
+
{ name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
|
|
323
|
+
],
|
|
324
|
+
visible: 'record.status == "pending"',
|
|
325
|
+
locations: ['record_section'],
|
|
326
|
+
successMessage: 'Information requested.',
|
|
327
|
+
refreshAfter: true,
|
|
328
|
+
},
|
|
329
|
+
|
|
330
|
+
// ── Submitter continuity actions ────────────────────────────────
|
|
331
|
+
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
332
|
+
// submitter's own levers, so `visible` gates on `submitter_id == ctx.user.id`
|
|
333
|
+
// — the current user is exposed via the console's predicate scope. The
|
|
334
|
+
// service re-checks ownership; the predicate keeps a non-submitter from ever
|
|
335
|
+
// seeing a button they cannot use.
|
|
336
|
+
{
|
|
337
|
+
name: 'approval_remind',
|
|
338
|
+
label: 'Send reminder',
|
|
339
|
+
icon: 'bell-ring',
|
|
340
|
+
type: 'api',
|
|
341
|
+
method: 'POST',
|
|
342
|
+
target: '/api/v1/approvals/requests/{id}/remind',
|
|
343
|
+
params: [
|
|
344
|
+
{ name: 'comment', label: 'Note', type: 'textarea', required: false },
|
|
345
|
+
],
|
|
346
|
+
visible: 'record.status == "pending" && record.submitter_id == ctx.user.id',
|
|
347
|
+
locations: ['record_section'],
|
|
348
|
+
successMessage: 'Reminder sent.',
|
|
349
|
+
refreshAfter: true,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: 'approval_recall',
|
|
353
|
+
label: 'Recall',
|
|
354
|
+
icon: 'undo-2',
|
|
355
|
+
type: 'api',
|
|
356
|
+
method: 'POST',
|
|
357
|
+
target: '/api/v1/approvals/requests/{id}/recall',
|
|
358
|
+
params: [
|
|
359
|
+
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
360
|
+
],
|
|
361
|
+
// Recall applies while the request is live for the submitter — pending
|
|
362
|
+
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
363
|
+
visible: '(record.status == "pending" || record.status == "returned") && record.submitter_id == ctx.user.id',
|
|
364
|
+
confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
|
|
365
|
+
locations: ['record_section'],
|
|
366
|
+
successMessage: 'Recalled.',
|
|
367
|
+
refreshAfter: true,
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
name: 'approval_resubmit',
|
|
371
|
+
label: 'Resubmit',
|
|
372
|
+
icon: 'refresh-cw',
|
|
373
|
+
type: 'api',
|
|
374
|
+
method: 'POST',
|
|
375
|
+
target: '/api/v1/approvals/requests/{id}/resubmit',
|
|
376
|
+
params: [
|
|
377
|
+
{ name: 'comment', label: 'What changed?', type: 'textarea', required: false },
|
|
378
|
+
],
|
|
379
|
+
visible: 'record.status == "returned" && record.submitter_id == ctx.user.id',
|
|
380
|
+
locations: ['record_section'],
|
|
381
|
+
successMessage: 'Resubmitted.',
|
|
382
|
+
refreshAfter: true,
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
|
|
386
|
+
enable: {
|
|
387
|
+
// [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
|
|
388
|
+
// (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
|
|
389
|
+
// generic CRUD. Reads stay open.
|
|
390
|
+
apiMethods: ['get', 'list'],
|
|
391
|
+
},
|
|
232
392
|
});
|
|
@@ -92,4 +92,10 @@ export const SysApprovalToken = ObjectSchema.create({
|
|
|
92
92
|
{ fields: ['token_hash'] },
|
|
93
93
|
{ fields: ['request_id'] },
|
|
94
94
|
],
|
|
95
|
+
|
|
96
|
+
enable: {
|
|
97
|
+
// [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
|
|
98
|
+
// consumed by the approval engine (SYSTEM_CTX), never via the data API.
|
|
99
|
+
apiMethods: ['get', 'list'],
|
|
100
|
+
},
|
|
95
101
|
});
|
|
@@ -152,5 +152,54 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
152
152
|
label: "All"
|
|
153
153
|
}
|
|
154
154
|
}
|
|
155
|
+
},
|
|
156
|
+
sys_approval_delegation: {
|
|
157
|
+
label: "Approval Delegation",
|
|
158
|
+
pluralLabel: "Approval Delegations",
|
|
159
|
+
description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
|
|
160
|
+
fields: {
|
|
161
|
+
id: {
|
|
162
|
+
label: "Delegation ID"
|
|
163
|
+
},
|
|
164
|
+
delegator_id: {
|
|
165
|
+
label: "Delegator",
|
|
166
|
+
help: "The user going out of office; their individually-routed approver slots are rerouted while active."
|
|
167
|
+
},
|
|
168
|
+
delegate_id: {
|
|
169
|
+
label: "Delegate",
|
|
170
|
+
help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
|
|
171
|
+
},
|
|
172
|
+
valid_from: {
|
|
173
|
+
label: "Valid From",
|
|
174
|
+
help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) — never by a background job."
|
|
175
|
+
},
|
|
176
|
+
valid_until: {
|
|
177
|
+
label: "Valid Until",
|
|
178
|
+
help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
|
|
179
|
+
},
|
|
180
|
+
reason: {
|
|
181
|
+
label: "Reason",
|
|
182
|
+
help: "Why the delegation exists (e.g. \"Annual leave 5/26\u20135/30\"). Recorded on the substitution audit row."
|
|
183
|
+
},
|
|
184
|
+
organization_id: {
|
|
185
|
+
label: "Organization",
|
|
186
|
+
help: "Tenant that owns this rule; null = applies across tenants for this delegator."
|
|
187
|
+
},
|
|
188
|
+
created_at: {
|
|
189
|
+
label: "Created At"
|
|
190
|
+
},
|
|
191
|
+
updated_at: {
|
|
192
|
+
label: "Updated At"
|
|
193
|
+
}
|
|
194
|
+
},
|
|
195
|
+
_views: {
|
|
196
|
+
active: {
|
|
197
|
+
label: "Active",
|
|
198
|
+
emptyState: {
|
|
199
|
+
title: "No delegations",
|
|
200
|
+
message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
155
204
|
}
|
|
156
205
|
};
|