@objectstack/plugin-approvals 17.0.0-rc.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 +439 -0
- package/dist/index.d.mts +2304 -3491
- package/dist/index.d.ts +2304 -3491
- package/dist/index.js +116 -43
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +114 -41
- package/dist/index.mjs.map +1 -1
- package/package.json +16 -9
- package/.turbo/turbo-build.log +0 -22
- package/scripts/i18n-extract.config.ts +0 -38
- package/src/action-link-pages.ts +0 -102
- package/src/approval-actor-impersonation.test.ts +0 -330
- package/src/approval-node.test.ts +0 -356
- package/src/approval-node.ts +0 -196
- package/src/approval-revise.test.ts +0 -418
- package/src/approval-service.test.ts +0 -2858
- package/src/approval-service.ts +0 -3617
- package/src/approvals-plugin.ts +0 -294
- package/src/approver-cross-org.integration.test.ts +0 -206
- package/src/approver-org-scope.test.ts +0 -201
- package/src/approver-org-scope.ts +0 -261
- package/src/index.ts +0 -42
- package/src/lifecycle-hooks.ts +0 -201
- package/src/nav-contribution.test.ts +0 -50
- package/src/record-lock-schedule-run.integration.test.ts +0 -206
- package/src/status-mirror-cascade.integration.test.ts +0 -224
- package/src/sys-approval-action.object.ts +0 -149
- package/src/sys-approval-approver.object.ts +0 -85
- package/src/sys-approval-delegation.object.test.ts +0 -42
- package/src/sys-approval-delegation.object.ts +0 -142
- package/src/sys-approval-request.object.test.ts +0 -116
- package/src/sys-approval-request.object.ts +0 -413
- package/src/sys-approval-token.object.ts +0 -101
- package/src/translations/bundle-ownership.test.ts +0 -48
- package/src/translations/en.objects.generated.ts +0 -311
- package/src/translations/es-ES.objects.generated.ts +0 -311
- package/src/translations/index.ts +0 -23
- package/src/translations/ja-JP.objects.generated.ts +0 -311
- package/src/translations/zh-CN.objects.generated.ts +0 -311
- package/tsconfig.json +0 -10
|
@@ -1,149 +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
|
-
// ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
|
|
30
|
-
// are OWNED by this row — and the storage service would otherwise authorize
|
|
31
|
-
// their download by testing whether the caller can READ this row. It cannot:
|
|
32
|
-
// this table is deliberately closed to ordinary approver positions, so that
|
|
33
|
-
// test denies the very approver the attachment was filed for. The approvals
|
|
34
|
-
// service already owns the rule for seeing a decision (visibility of the
|
|
35
|
-
// parent request, exactly as `listActions` applies it), so it answers.
|
|
36
|
-
fileAccessDelegate: 'approvals',
|
|
37
|
-
|
|
38
|
-
listViews: {
|
|
39
|
-
recent: {
|
|
40
|
-
type: 'grid',
|
|
41
|
-
name: 'recent',
|
|
42
|
-
label: 'Recent',
|
|
43
|
-
data: { provider: 'object', object: 'sys_approval_action' },
|
|
44
|
-
columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'],
|
|
45
|
-
sort: [{ field: 'created_at', order: 'desc' }],
|
|
46
|
-
pagination: { pageSize: 50 },
|
|
47
|
-
emptyState: { title: 'No approval actions yet', message: 'Actions are logged automatically when approvals progress.' },
|
|
48
|
-
},
|
|
49
|
-
by_actor: {
|
|
50
|
-
type: 'grid',
|
|
51
|
-
name: 'by_actor',
|
|
52
|
-
label: 'By Actor',
|
|
53
|
-
data: { provider: 'object', object: 'sys_approval_action' },
|
|
54
|
-
columns: ['actor_id', 'created_at', 'request_id', 'step_name', 'action'],
|
|
55
|
-
sort: [{ field: 'actor_id', order: 'asc' }, { field: 'created_at', order: 'desc' }],
|
|
56
|
-
grouping: { fields: [{ field: 'actor_id', order: 'asc', collapsed: false }] },
|
|
57
|
-
pagination: { pageSize: 100 },
|
|
58
|
-
},
|
|
59
|
-
all_actions: {
|
|
60
|
-
type: 'grid',
|
|
61
|
-
name: 'all_actions',
|
|
62
|
-
label: 'All',
|
|
63
|
-
data: { provider: 'object', object: 'sys_approval_action' },
|
|
64
|
-
columns: ['created_at', 'request_id', 'step_name', 'action', 'actor_id', 'comment'],
|
|
65
|
-
sort: [{ field: 'created_at', order: 'desc' }],
|
|
66
|
-
pagination: { pageSize: 100 },
|
|
67
|
-
},
|
|
68
|
-
},
|
|
69
|
-
|
|
70
|
-
fields: {
|
|
71
|
-
id: Field.text({ label: 'Action ID', required: true, readonly: true, group: 'System' }),
|
|
72
|
-
|
|
73
|
-
organization_id: Field.lookup('sys_organization', {
|
|
74
|
-
label: 'Organization',
|
|
75
|
-
required: false,
|
|
76
|
-
group: 'System',
|
|
77
|
-
description: 'Tenant that owns this action (mirrors the parent request)',
|
|
78
|
-
}),
|
|
79
|
-
|
|
80
|
-
request_id: Field.lookup('sys_approval_request', {
|
|
81
|
-
label: 'Request',
|
|
82
|
-
required: true,
|
|
83
|
-
group: 'Target',
|
|
84
|
-
}),
|
|
85
|
-
|
|
86
|
-
step_name: Field.text({
|
|
87
|
-
label: 'Step',
|
|
88
|
-
required: false,
|
|
89
|
-
maxLength: 100,
|
|
90
|
-
description: 'Machine name of the step at the time of the action',
|
|
91
|
-
group: 'Target',
|
|
92
|
-
}),
|
|
93
|
-
|
|
94
|
-
step_index: Field.number({
|
|
95
|
-
label: 'Step Index',
|
|
96
|
-
required: false,
|
|
97
|
-
group: 'Target',
|
|
98
|
-
}),
|
|
99
|
-
|
|
100
|
-
action: Field.select(
|
|
101
|
-
// Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
|
|
102
|
-
// remind / request_info / comment are thread interactions — they never
|
|
103
|
-
// move the flow. revise / resubmit (ADR-0044) DO move it: send back for
|
|
104
|
-
// revision and the later resubmission. ooo_substitute (#1322 M1) is a
|
|
105
|
-
// system-recorded reroute of an out-of-office approver — no flow movement.
|
|
106
|
-
['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment', 'revise', 'resubmit', 'ooo_substitute'],
|
|
107
|
-
{
|
|
108
|
-
label: 'Action',
|
|
109
|
-
required: true,
|
|
110
|
-
group: 'Action',
|
|
111
|
-
},
|
|
112
|
-
),
|
|
113
|
-
|
|
114
|
-
actor_id: Field.lookup('sys_user', {
|
|
115
|
-
label: 'Actor',
|
|
116
|
-
required: false,
|
|
117
|
-
group: 'Action',
|
|
118
|
-
}),
|
|
119
|
-
|
|
120
|
-
comment: Field.textarea({ label: 'Comment', required: false, group: 'Action' }),
|
|
121
|
-
|
|
122
|
-
attachments: Field.file({
|
|
123
|
-
label: 'Attachments',
|
|
124
|
-
required: false,
|
|
125
|
-
multiple: true,
|
|
126
|
-
group: 'Action',
|
|
127
|
-
description: 'Files supporting this action — e.g. a signed contract or evidence (#3266).',
|
|
128
|
-
}),
|
|
129
|
-
|
|
130
|
-
created_at: Field.datetime({
|
|
131
|
-
label: 'Created At',
|
|
132
|
-
required: true,
|
|
133
|
-
defaultValue: 'NOW()',
|
|
134
|
-
readonly: true,
|
|
135
|
-
group: 'System',
|
|
136
|
-
}),
|
|
137
|
-
},
|
|
138
|
-
|
|
139
|
-
indexes: [
|
|
140
|
-
{ fields: ['request_id', 'created_at'] },
|
|
141
|
-
{ fields: ['request_id', 'step_index', 'action'] },
|
|
142
|
-
],
|
|
143
|
-
|
|
144
|
-
enable: {
|
|
145
|
-
// [ADR-0103] Engine-owned append-only decision log: appended by the approval
|
|
146
|
-
// engine (SYSTEM_CTX). Reads stay open.
|
|
147
|
-
apiMethods: ['get', 'list'],
|
|
148
|
-
},
|
|
149
|
-
});
|
|
@@ -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,42 +0,0 @@
|
|
|
1
|
-
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* #3026 follow-up — `sys_approval_delegation` must expose the BATCH shape of
|
|
5
|
-
* the write verbs it already grants.
|
|
6
|
-
*
|
|
7
|
-
* The object is `managedBy: 'system'` but opens generic writes deliberately
|
|
8
|
-
* (`userActions: { create, edit, delete }` — an out-of-office rule is authored
|
|
9
|
-
* by its own user through the plain data endpoint), so the ADR-0103 D3
|
|
10
|
-
* reconciliation strips nothing and its boilerplate CRUD-five whitelist reaches
|
|
11
|
-
* the REST gate as authored. Since the #3391 P1 contract made bulk
|
|
12
|
-
* `bulk ∧ derived(child)`, that whitelist — which never named the `bulk`
|
|
13
|
-
* primitive — 405s every batch route while the single-record verbs stay open.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { describe, expect, it } from 'vitest';
|
|
17
|
-
import { resolveEffectiveApiMethods, isApiOperationAllowed } from '@objectstack/spec/data';
|
|
18
|
-
import { SysApprovalDelegation } from './sys-approval-delegation.object';
|
|
19
|
-
|
|
20
|
-
describe('sys_approval_delegation — batch exposure (#3026 / #3391 P1 companion)', () => {
|
|
21
|
-
it('grants the bulk primitive alongside its single-record write verbs', () => {
|
|
22
|
-
expect(SysApprovalDelegation.enable?.apiMethods).toContain('bulk');
|
|
23
|
-
for (const verb of ['get', 'list', 'create', 'update', 'delete'] as const) {
|
|
24
|
-
expect(SysApprovalDelegation.enable?.apiMethods, `must keep ${verb}`).toContain(verb);
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
|
|
28
|
-
it('admits createMany / updateMany / deleteMany and /batch', () => {
|
|
29
|
-
const eff = resolveEffectiveApiMethods(SysApprovalDelegation.enable);
|
|
30
|
-
expect(eff.mode).toBe('restricted');
|
|
31
|
-
for (const child of ['create', 'update', 'delete'] as const) {
|
|
32
|
-
expect(isApiOperationAllowed(eff, 'bulk', { bulkChild: child }), `batch ${child}`).toBe(true);
|
|
33
|
-
}
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
it('keeps the whitelist explicit so the ADR-0103 D3 backstop still runs', () => {
|
|
37
|
-
// `reconcileManagedApiMethods` early-returns on a non-array `apiMethods`.
|
|
38
|
-
// For a `managedBy` object, deleting the whitelist would silently disable
|
|
39
|
-
// managed-write stripping — it is not an equivalent refactor.
|
|
40
|
-
expect(Array.isArray(SysApprovalDelegation.enable?.apiMethods)).toBe(true);
|
|
41
|
-
});
|
|
42
|
-
});
|
|
@@ -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
|
-
// `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
|
|
139
|
-
// (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
|
|
140
|
-
apiMethods: ['get', 'list', 'create', 'update', 'delete', 'bulk'],
|
|
141
|
-
},
|
|
142
|
-
});
|
|
@@ -1,116 +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('the core decision levers OR in the admin override (#3424) so a stuck request is recoverable', () => {
|
|
78
|
-
// approve / reject / reassign additionally show for a platform/tenant admin
|
|
79
|
-
// (`record.viewer.can_override`) so an approval routed to an unstaffed
|
|
80
|
-
// position — otherwise undecidable, locking the record forever — can be
|
|
81
|
-
// rescued in-product. The secondary approver levers stay slot-only.
|
|
82
|
-
for (const name of ['approval_approve', 'approval_reject', 'approval_reassign']) {
|
|
83
|
-
expect(vis(name)).toContain('record.viewer.can_override');
|
|
84
|
-
}
|
|
85
|
-
for (const name of ['approval_send_back', 'approval_request_info']) {
|
|
86
|
-
expect(vis(name)).not.toContain('can_override');
|
|
87
|
-
}
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
|
|
91
|
-
expect(vis('approval_recall')).toContain('record.status == "returned"');
|
|
92
|
-
expect(byName('approval_recall').confirmText).toBeTruthy();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
it('reassign collects the new approver via a field-backed sys_user picker keyed as `to`', () => {
|
|
96
|
-
const toParam = byName('approval_reassign').params.find((p: any) => p.name === 'to');
|
|
97
|
-
expect(toParam).toMatchObject({ field: 'submitter_id', name: 'to', required: true });
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
it('request-info requires a comment; other params stay optional', () => {
|
|
101
|
-
const ri = byName('approval_request_info').params.find((p: any) => p.name === 'comment');
|
|
102
|
-
expect(ri.required).toBe(true);
|
|
103
|
-
for (const name of ['approval_send_back', 'approval_remind', 'approval_recall', 'approval_resubmit']) {
|
|
104
|
-
const c = byName(name).params.find((p: any) => p.name === 'comment');
|
|
105
|
-
expect(c.required ?? false).toBe(false);
|
|
106
|
-
}
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
it('approve/reject collect optional multi-file decision attachments', () => {
|
|
110
|
-
for (const name of ['approval_approve', 'approval_reject']) {
|
|
111
|
-
const att = byName(name).params.find((p: any) => p.name === 'attachments');
|
|
112
|
-
expect(att, `${name}.attachments`).toMatchObject({ type: 'file', multiple: true });
|
|
113
|
-
expect(att.required ?? false).toBe(false);
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
});
|