@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,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
- });