@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.
- package/CHANGELOG.md +1462 -0
- package/dist/index.d.mts +2853 -3925
- package/dist/index.d.ts +2853 -3925
- package/dist/index.js +1819 -240
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1817 -230
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -7
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -33
- package/src/action-link-pages.ts +0 -102
- package/src/approval-node.test.ts +0 -196
- package/src/approval-node.ts +0 -139
- package/src/approval-revise.test.ts +0 -411
- package/src/approval-service.test.ts +0 -1490
- package/src/approval-service.ts +0 -2360
- package/src/approvals-plugin.ts +0 -263
- package/src/index.ts +0 -39
- package/src/lifecycle-hooks.ts +0 -179
- package/src/nav-contribution.test.ts +0 -50
- package/src/sys-approval-action.object.ts +0 -140
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -103
- package/src/sys-approval-request.object.ts +0 -401
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/en.objects.generated.ts +0 -205
- package/src/translations/es-ES.objects.generated.ts +0 -205
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -205
- package/src/translations/zh-CN.objects.generated.ts +0 -205
- package/tsconfig.json +0 -10
|
@@ -1,85 +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_approver — Pending-approver index (issue #1745).
|
|
7
|
-
*
|
|
8
|
-
* One row per (request, approver identity) while the request is **pending**.
|
|
9
|
-
* `sys_approval_request.pending_approvers` stays the human-readable source of
|
|
10
|
-
* truth (a CSV column), but CSV substring matching can neither be indexed nor
|
|
11
|
-
* pushed into an engine query — which made "my pending" a post-filter in
|
|
12
|
-
* memory and broke pagination beyond the scan window.
|
|
13
|
-
*
|
|
14
|
-
* This table is that CSV, normalized: the service mirrors every change to
|
|
15
|
-
* `pending_approvers` here (open / decide / recall / send-back / reassign /
|
|
16
|
-
* escalate), and clears the rows when the request leaves `pending`. So the
|
|
17
|
-
* table only ever holds the live work queue — its size tracks the number of
|
|
18
|
-
* open approvals, not the append-only request history.
|
|
19
|
-
*
|
|
20
|
-
* `approver` holds one identity literal exactly as it appears in the CSV:
|
|
21
|
-
* a user id, an email, or a `role:<name>` / `team:<name>` style literal.
|
|
22
|
-
* Equality (or `$in`) on this column is the indexed replacement for the old
|
|
23
|
-
* per-row substring match.
|
|
24
|
-
*
|
|
25
|
-
* @namespace sys
|
|
26
|
-
*/
|
|
27
|
-
export const SysApprovalApprover = ObjectSchema.create({
|
|
28
|
-
name: 'sys_approval_approver',
|
|
29
|
-
label: 'Approval Approver',
|
|
30
|
-
pluralLabel: 'Approval Approvers',
|
|
31
|
-
icon: 'users',
|
|
32
|
-
isSystem: true,
|
|
33
|
-
managedBy: 'engine-owned',
|
|
34
|
-
description: 'Normalized pending-approver rows for indexed inbox queries',
|
|
35
|
-
displayNameField: 'id',
|
|
36
|
-
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
37
|
-
titleFormat: '{approver} · {request_id}',
|
|
38
|
-
highlightFields: ['request_id', 'approver', 'created_at'],
|
|
39
|
-
|
|
40
|
-
fields: {
|
|
41
|
-
id: Field.text({ label: 'Row ID', required: true, readonly: true, group: 'System' }),
|
|
42
|
-
|
|
43
|
-
organization_id: Field.lookup('sys_organization', {
|
|
44
|
-
label: 'Organization',
|
|
45
|
-
required: false,
|
|
46
|
-
group: 'System',
|
|
47
|
-
description: 'Tenant that owns this row (mirrors the parent request)',
|
|
48
|
-
}),
|
|
49
|
-
|
|
50
|
-
request_id: Field.lookup('sys_approval_request', {
|
|
51
|
-
label: 'Request',
|
|
52
|
-
required: true,
|
|
53
|
-
group: 'Target',
|
|
54
|
-
}),
|
|
55
|
-
|
|
56
|
-
approver: Field.text({
|
|
57
|
-
label: 'Approver',
|
|
58
|
-
required: true,
|
|
59
|
-
maxLength: 255,
|
|
60
|
-
description: 'One pending-approver identity: user id, email, or role:/team: literal',
|
|
61
|
-
group: 'Target',
|
|
62
|
-
}),
|
|
63
|
-
|
|
64
|
-
created_at: Field.datetime({
|
|
65
|
-
label: 'Created At',
|
|
66
|
-
required: true,
|
|
67
|
-
defaultValue: 'NOW()',
|
|
68
|
-
readonly: true,
|
|
69
|
-
group: 'System',
|
|
70
|
-
}),
|
|
71
|
-
},
|
|
72
|
-
|
|
73
|
-
indexes: [
|
|
74
|
-
// "My pending" inbox: equality on the identity literal, scoped by tenant.
|
|
75
|
-
{ fields: ['approver', 'organization_id'] },
|
|
76
|
-
// Sync path: rewrite all rows of one request on each approver-set change.
|
|
77
|
-
{ fields: ['request_id'] },
|
|
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
|
-
},
|
|
85
|
-
});
|
|
@@ -1,142 +0,0 @@
|
|
|
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
|
-
});
|
|
@@ -1,103 +0,0 @@
|
|
|
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 on the server-computed viewer block (#3310): approver actions on can_act, submitter levers on is_submitter', () => {
|
|
65
|
-
for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
|
|
66
|
-
expect(vis(name)).toContain('record.viewer.is_submitter');
|
|
67
|
-
expect(vis(name)).not.toContain('can_act');
|
|
68
|
-
}
|
|
69
|
-
for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
|
|
70
|
-
expect(vis(name)).toContain('record.viewer.can_act');
|
|
71
|
-
// who-can-act is server-derived, never a client identity guess
|
|
72
|
-
expect(vis(name)).not.toContain('ctx.user.id');
|
|
73
|
-
expect(vis(name)).not.toContain('is_submitter');
|
|
74
|
-
}
|
|
75
|
-
});
|
|
76
|
-
|
|
77
|
-
it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
|
|
78
|
-
expect(vis('approval_recall')).toContain('record.status == "returned"');
|
|
79
|
-
expect(byName('approval_recall').confirmText).toBeTruthy();
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('reassign collects the new approver via a field-backed sys_user picker keyed as `to`', () => {
|
|
83
|
-
const toParam = byName('approval_reassign').params.find((p: any) => p.name === 'to');
|
|
84
|
-
expect(toParam).toMatchObject({ field: 'submitter_id', name: 'to', required: true });
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it('request-info requires a comment; other params stay optional', () => {
|
|
88
|
-
const ri = byName('approval_request_info').params.find((p: any) => p.name === 'comment');
|
|
89
|
-
expect(ri.required).toBe(true);
|
|
90
|
-
for (const name of ['approval_send_back', 'approval_remind', 'approval_recall', 'approval_resubmit']) {
|
|
91
|
-
const c = byName(name).params.find((p: any) => p.name === 'comment');
|
|
92
|
-
expect(c.required ?? false).toBe(false);
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('approve/reject collect optional multi-file decision attachments', () => {
|
|
97
|
-
for (const name of ['approval_approve', 'approval_reject']) {
|
|
98
|
-
const att = byName(name).params.find((p: any) => p.name === 'attachments');
|
|
99
|
-
expect(att, `${name}.attachments`).toMatchObject({ type: 'file', multiple: true });
|
|
100
|
-
expect(att.required ?? false).toBe(false);
|
|
101
|
-
}
|
|
102
|
-
});
|
|
103
|
-
});
|