@objectstack/plugin-approvals 16.1.0 → 17.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 +1023 -0
- package/dist/index.d.mts +650 -535
- package/dist/index.d.ts +650 -535
- package/dist/index.js +1713 -207
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1711 -197
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -7
- package/scripts/i18n-extract.config.ts +6 -1
- package/src/approval-actor-impersonation.test.ts +330 -0
- package/src/approval-node.test.ts +160 -0
- package/src/approval-node.ts +57 -0
- package/src/approval-revise.test.ts +41 -34
- package/src/approval-service.test.ts +1408 -40
- package/src/approval-service.ts +1364 -107
- package/src/approvals-plugin.ts +36 -5
- package/src/approver-cross-org.integration.test.ts +206 -0
- package/src/approver-org-scope.test.ts +201 -0
- package/src/approver-org-scope.ts +261 -0
- package/src/index.ts +3 -0
- package/src/lifecycle-hooks.ts +22 -0
- package/src/record-lock-schedule-run.integration.test.ts +206 -0
- package/src/status-mirror-cascade.integration.test.ts +224 -0
- package/src/sys-approval-action.object.ts +9 -0
- package/src/sys-approval-delegation.object.test.ts +42 -0
- package/src/sys-approval-delegation.object.ts +3 -3
- package/src/sys-approval-request.object.test.ts +13 -0
- package/src/sys-approval-request.object.ts +17 -5
- package/src/translations/bundle-ownership.test.ts +48 -0
- package/src/translations/en.objects.generated.ts +111 -5
- package/src/translations/es-ES.objects.generated.ts +111 -5
- package/src/translations/ja-JP.objects.generated.ts +111 -5
- package/src/translations/zh-CN.objects.generated.ts +110 -4
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #3783 — an approval decision cascades as the deciding user.
|
|
5
|
+
*
|
|
6
|
+
* "When the invoice is approved, do X" is the single most natural approvals
|
|
7
|
+
* automation there is, and until now it could not be written the obvious way.
|
|
8
|
+
* The status mirror — the write that puts `approved` on the business record, and
|
|
9
|
+
* therefore the write that fires that object's record-change flows — presented a
|
|
10
|
+
* bare `{ isSystem: true }` context with no `userId`. `isSystem` does not
|
|
11
|
+
* suppress trigger dispatch, so the flow DID fire; it just fired with no trigger
|
|
12
|
+
* user, and since #3760 a `runAs:'user'` run with no trigger user has its data
|
|
13
|
+
* operations refused. Authors were pushed to declare `runAs:'system'` — blanket
|
|
14
|
+
* elevation — for a case where a perfectly good scoped identity existed all
|
|
15
|
+
* along, sitting right there at the call site.
|
|
16
|
+
*
|
|
17
|
+
* The seam is invisible in a unit test of any single hop, so this one refuses to
|
|
18
|
+
* stub any of them: a real {@link ObjectKernel} with the real ObjectQL engine,
|
|
19
|
+
* the real record-change trigger, the real automation engine, and the real
|
|
20
|
+
* {@link ApprovalService}. The mirror is produced by an actual decision, not
|
|
21
|
+
* hand-written.
|
|
22
|
+
*
|
|
23
|
+
* The negative case is load-bearing, not decoration. It fires the SAME flow off
|
|
24
|
+
* the dead-run sweep's mirror, which has no human behind it and stays user-less
|
|
25
|
+
* on purpose — and shows it is still refused. Without it, the positive case
|
|
26
|
+
* would prove only that the flow runs, never that it runs *because* the identity
|
|
27
|
+
* arrived.
|
|
28
|
+
*
|
|
29
|
+
* The record lock is deliberately not bound here: it is a separate concern with
|
|
30
|
+
* its own end-to-end coverage (`record-lock-schedule-run.integration.test.ts`).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
34
|
+
import { ObjectKernel } from '@objectstack/core';
|
|
35
|
+
import { ObjectQLPlugin } from '@objectstack/objectql';
|
|
36
|
+
import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation';
|
|
37
|
+
import { RecordChangeTriggerPlugin } from '@objectstack/trigger-record-change';
|
|
38
|
+
import { ApprovalService } from './approval-service.js';
|
|
39
|
+
import { SysApprovalRequest } from './sys-approval-request.object.js';
|
|
40
|
+
import { SysApprovalAction } from './sys-approval-action.object.js';
|
|
41
|
+
import { SysApprovalApprover } from './sys-approval-approver.object.js';
|
|
42
|
+
|
|
43
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
|
|
45
|
+
const SUBMITTER = { userId: 'submitter', positions: [], permissions: [] } as any;
|
|
46
|
+
const APPROVER = { userId: 'approver', positions: [], permissions: [] } as any;
|
|
47
|
+
|
|
48
|
+
/** Equality-WHERE in-memory driver — the same shape the trigger's own e2e uses. */
|
|
49
|
+
function makeMemoryDriver(): any {
|
|
50
|
+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
|
|
51
|
+
const storeFor = (obj: string) => {
|
|
52
|
+
let s = stores.get(obj);
|
|
53
|
+
if (!s) { s = new Map(); stores.set(obj, s); }
|
|
54
|
+
return s;
|
|
55
|
+
};
|
|
56
|
+
let nextId = 0;
|
|
57
|
+
const matches = (row: Record<string, unknown>, where: any): boolean => {
|
|
58
|
+
if (!where || typeof where !== 'object') return true;
|
|
59
|
+
if (Array.isArray(where.$and)) return where.$and.every((w: any) => matches(row, w));
|
|
60
|
+
if (Array.isArray(where.$or)) return where.$or.some((w: any) => matches(row, w));
|
|
61
|
+
for (const [k, v] of Object.entries(where)) {
|
|
62
|
+
if (k.startsWith('$')) continue;
|
|
63
|
+
const expected = v && typeof v === 'object' && '$eq' in (v as any) ? (v as any).$eq : v;
|
|
64
|
+
const a = row[k] === undefined ? null : row[k];
|
|
65
|
+
const b = expected === undefined ? null : expected;
|
|
66
|
+
if (a !== b) return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
return {
|
|
71
|
+
name: 'memory', version: '0.0.0', supports: {},
|
|
72
|
+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
|
|
73
|
+
async execute() { return null; }, async syncSchema() {},
|
|
74
|
+
async find(object: string, ast: any) {
|
|
75
|
+
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where));
|
|
76
|
+
},
|
|
77
|
+
findStream() { throw new Error('not implemented'); },
|
|
78
|
+
async findOne(object: string, ast: any) {
|
|
79
|
+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return r;
|
|
80
|
+
return null;
|
|
81
|
+
},
|
|
82
|
+
async create(object: string, data: Record<string, unknown>) {
|
|
83
|
+
nextId += 1;
|
|
84
|
+
const id = (data.id as string) ?? `r_${nextId}`;
|
|
85
|
+
const row = { ...data, id };
|
|
86
|
+
storeFor(object).set(id, row);
|
|
87
|
+
return row;
|
|
88
|
+
},
|
|
89
|
+
async update(object: string, id: string, data: Record<string, unknown>) {
|
|
90
|
+
const s = storeFor(object);
|
|
91
|
+
const cur = s.get(id);
|
|
92
|
+
if (!cur) throw new Error(`not found: ${object}/${id}`);
|
|
93
|
+
const updated = { ...cur, ...data, id };
|
|
94
|
+
s.set(id, updated);
|
|
95
|
+
return updated;
|
|
96
|
+
},
|
|
97
|
+
async upsert(object: string, data: Record<string, unknown>) {
|
|
98
|
+
const id = data.id as string | undefined;
|
|
99
|
+
if (id && storeFor(object).has(id)) return this.update(object, id, data);
|
|
100
|
+
return this.create(object, data);
|
|
101
|
+
},
|
|
102
|
+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
|
|
103
|
+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
|
|
104
|
+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
|
|
105
|
+
return Promise.all(rows.map((r) => this.create(object, r)));
|
|
106
|
+
},
|
|
107
|
+
async bulkUpdate() { return []; }, async bulkDelete() {},
|
|
108
|
+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
|
|
109
|
+
async commit() {}, async rollback() {},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const opportunity = {
|
|
114
|
+
name: 'opportunity',
|
|
115
|
+
label: 'Opportunity',
|
|
116
|
+
fields: {
|
|
117
|
+
amount: { name: 'amount', label: 'Amount', type: 'number' },
|
|
118
|
+
approval_status: { name: 'approval_status', label: 'Approval Status', type: 'text' },
|
|
119
|
+
cascaded: { name: 'cascaded', label: 'Cascaded', type: 'text' },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The automation an author actually wants to write: react to the approval, no
|
|
125
|
+
* `runAs` — the spec default `'user'`. That default is the whole point; a flow
|
|
126
|
+
* forced to say `runAs:'system'` to work at all is the bug being fixed.
|
|
127
|
+
*/
|
|
128
|
+
const onApprovedFlow = {
|
|
129
|
+
name: 'on_approved',
|
|
130
|
+
label: 'On Approved',
|
|
131
|
+
type: 'record_change',
|
|
132
|
+
nodes: [
|
|
133
|
+
{
|
|
134
|
+
id: 'start', type: 'start', label: 'Start',
|
|
135
|
+
config: {
|
|
136
|
+
objectName: 'opportunity',
|
|
137
|
+
triggerType: 'record-after-update',
|
|
138
|
+
// Fires on BOTH terminal mirrors, so the two cases below differ only in
|
|
139
|
+
// whether the mirror that fired it carried a user — nothing else.
|
|
140
|
+
condition: "approval_status == 'approved' || approval_status == 'recalled'",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
id: 'stamp', type: 'update_record', label: 'Stamp',
|
|
145
|
+
config: { objectName: 'opportunity', filter: { id: '{record.id}' }, fields: { cascaded: 'yes' } },
|
|
146
|
+
},
|
|
147
|
+
{ id: 'end', type: 'end', label: 'End' },
|
|
148
|
+
],
|
|
149
|
+
edges: [
|
|
150
|
+
{ id: 'e1', source: 'start', target: 'stamp' },
|
|
151
|
+
{ id: 'e2', source: 'stamp', target: 'end' },
|
|
152
|
+
],
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const nodeConfig = {
|
|
156
|
+
approvers: [{ type: 'user' as const, value: 'approver' }],
|
|
157
|
+
behavior: 'first_response' as const,
|
|
158
|
+
lockRecord: false,
|
|
159
|
+
approvalStatusField: 'approval_status',
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
describe('an approval decision cascades as the deciding user (#3783)', () => {
|
|
163
|
+
let data: any;
|
|
164
|
+
let svc: ApprovalService;
|
|
165
|
+
|
|
166
|
+
beforeEach(async () => {
|
|
167
|
+
const kernel = new ObjectKernel({ logLevel: 'silent' });
|
|
168
|
+
await kernel.use(new ObjectQLPlugin());
|
|
169
|
+
await kernel.use(new AutomationServicePlugin());
|
|
170
|
+
await kernel.use(new RecordChangeTriggerPlugin());
|
|
171
|
+
await kernel.bootstrap();
|
|
172
|
+
|
|
173
|
+
const objectql = kernel.getService('objectql') as any;
|
|
174
|
+
data = kernel.getService('data') as any;
|
|
175
|
+
const automation = kernel.getService<AutomationEngine>('automation');
|
|
176
|
+
|
|
177
|
+
objectql.registerDriver(makeMemoryDriver(), true);
|
|
178
|
+
for (const def of [opportunity, SysApprovalRequest, SysApprovalAction, SysApprovalApprover]) {
|
|
179
|
+
objectql.registry.registerObject(def as any, 'approvals-test', 'approvals-test');
|
|
180
|
+
}
|
|
181
|
+
automation.registerFlow('on_approved', onApprovedFlow as any);
|
|
182
|
+
|
|
183
|
+
svc = new ApprovalService({ engine: objectql });
|
|
184
|
+
await data.insert('opportunity', { id: 'opp1', amount: 100 }, { context: { isSystem: true } });
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const readBack = () => data.findOne('opportunity', { where: { id: 'opp1' } });
|
|
188
|
+
|
|
189
|
+
const openRequest = () => svc.openNodeRequest({
|
|
190
|
+
object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step',
|
|
191
|
+
flowName: 'deal_approval', config: nodeConfig, submitterId: 'submitter',
|
|
192
|
+
record: { id: 'opp1', amount: 100 },
|
|
193
|
+
}, SUBMITTER);
|
|
194
|
+
|
|
195
|
+
it('the approve mirror hands the flow a trigger user, so its data node runs', async () => {
|
|
196
|
+
const req = await openRequest();
|
|
197
|
+
await svc.decide(req.id as string, { decision: 'approve', actorId: 'approver' }, APPROVER);
|
|
198
|
+
await sleep(300);
|
|
199
|
+
|
|
200
|
+
const row = await readBack();
|
|
201
|
+
expect(row?.approval_status, 'the mirror itself must still land').toBe('approved');
|
|
202
|
+
// Before #3783 this stayed undefined: the run inherited no trigger user, so
|
|
203
|
+
// `resolveRunDataContext` refused its `update_record` outright.
|
|
204
|
+
expect(row?.cascaded).toBe('yes');
|
|
205
|
+
// ObjectQL's audit stamp is gated on the write context's `userId` alone —
|
|
206
|
+
// `isSystem` buys no exemption — so this is direct evidence the elevated
|
|
207
|
+
// mirror named a user rather than nobody.
|
|
208
|
+
expect(row?.updated_by).toBe('approver');
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('a dead-run release cascades user-less, and is still refused', async () => {
|
|
212
|
+
await openRequest();
|
|
213
|
+
svc.attachAutomation({ getRun: async () => ({ status: 'failed' }) } as any);
|
|
214
|
+
await svc.releaseDeadRunRequests();
|
|
215
|
+
await sleep(300);
|
|
216
|
+
|
|
217
|
+
const row = await readBack();
|
|
218
|
+
expect(row?.approval_status, 'the sweep must still release the record').toBe('recalled');
|
|
219
|
+
// No human abandoned this request — a sweep did. The cascade therefore has
|
|
220
|
+
// no identity to inherit and stays refused; an author who wants to react to
|
|
221
|
+
// a dead-run release declares `runAs:'system'` and means it.
|
|
222
|
+
expect(row?.cascaded).toBeFalsy();
|
|
223
|
+
});
|
|
224
|
+
});
|
|
@@ -26,6 +26,15 @@ export const SysApprovalAction = ObjectSchema.create({
|
|
|
26
26
|
titleFormat: '{action} · {step_name}',
|
|
27
27
|
highlightFields: ['request_id', 'step_name', 'action', 'actor_id', 'created_at'],
|
|
28
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
|
+
|
|
29
38
|
listViews: {
|
|
30
39
|
recent: {
|
|
31
40
|
type: 'grid',
|
|
@@ -0,0 +1,42 @@
|
|
|
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
|
+
});
|
|
@@ -135,8 +135,8 @@ export const SysApprovalDelegation = ObjectSchema.create({
|
|
|
135
135
|
trackHistory: true,
|
|
136
136
|
searchable: true,
|
|
137
137
|
apiEnabled: true,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
141
|
},
|
|
142
142
|
});
|
|
@@ -74,6 +74,19 @@ describe('sys_approval_request declared actions', () => {
|
|
|
74
74
|
}
|
|
75
75
|
});
|
|
76
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
|
+
|
|
77
90
|
it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
|
|
78
91
|
expect(vis('approval_recall')).toContain('record.status == "returned"');
|
|
79
92
|
expect(byName('approval_recall').confirmText).toBeTruthy();
|
|
@@ -240,13 +240,22 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
240
240
|
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
241
241
|
// (the caller is a current pending approver — same check the service
|
|
242
242
|
// authorizes a decision with, so position/team approvers resolve correctly),
|
|
243
|
-
// submitter actions on `record.viewer.is_submitter`.
|
|
244
|
-
//
|
|
243
|
+
// submitter actions on `record.viewer.is_submitter`. The core decision levers
|
|
244
|
+
// (approve/reject/reassign) additionally OR in `record.viewer.can_override`
|
|
245
|
+
// (#3424) so a platform/tenant admin can rescue a request routed to an
|
|
246
|
+
// unstaffed position — otherwise undecidable, locking the record forever — by
|
|
247
|
+
// approving, rejecting, or reassigning it to a real approver. `viewer` is
|
|
248
|
+
// attached by getRequest/listRequests; where it is absent the predicate fails
|
|
249
|
+
// closed.
|
|
245
250
|
actions: [
|
|
246
251
|
{
|
|
247
252
|
name: 'approval_approve',
|
|
248
253
|
label: 'Approve',
|
|
249
254
|
icon: 'check-circle',
|
|
255
|
+
// Primary decision — the console renders this filled/highlighted so it
|
|
256
|
+
// stands out from the secondary levers in the drawer's action bar,
|
|
257
|
+
// matching the mobile card hierarchy (objectui#2762 P1-5).
|
|
258
|
+
variant: 'primary',
|
|
250
259
|
type: 'api',
|
|
251
260
|
method: 'POST',
|
|
252
261
|
target: '/api/v1/approvals/requests/{id}/approve',
|
|
@@ -257,7 +266,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
257
266
|
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
258
267
|
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
|
|
259
268
|
],
|
|
260
|
-
visible: 'record.viewer.can_act',
|
|
269
|
+
visible: 'record.viewer.can_act || record.viewer.can_override',
|
|
261
270
|
locations: ['record_section', 'list_item'],
|
|
262
271
|
successMessage: 'Approved.',
|
|
263
272
|
refreshAfter: true,
|
|
@@ -266,6 +275,9 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
266
275
|
name: 'approval_reject',
|
|
267
276
|
label: 'Reject',
|
|
268
277
|
icon: 'x-circle',
|
|
278
|
+
// Destructive decision — rendered in the console's danger styling so it
|
|
279
|
+
// reads as the irreversible action it is (objectui#2762 P1-5).
|
|
280
|
+
variant: 'danger',
|
|
269
281
|
type: 'api',
|
|
270
282
|
method: 'POST',
|
|
271
283
|
target: '/api/v1/approvals/requests/{id}/reject',
|
|
@@ -273,7 +285,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
273
285
|
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
274
286
|
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
|
|
275
287
|
],
|
|
276
|
-
visible: 'record.viewer.can_act',
|
|
288
|
+
visible: 'record.viewer.can_act || record.viewer.can_override',
|
|
277
289
|
confirmText: 'Reject this request? A rejection is final for every approver.',
|
|
278
290
|
locations: ['record_section', 'list_item'],
|
|
279
291
|
successMessage: 'Rejected.',
|
|
@@ -295,7 +307,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
295
307
|
{ field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
|
|
296
308
|
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
297
309
|
],
|
|
298
|
-
visible: 'record.viewer.can_act',
|
|
310
|
+
visible: 'record.viewer.can_act || record.viewer.can_override',
|
|
299
311
|
locations: ['record_section'],
|
|
300
312
|
successMessage: 'Reassigned.',
|
|
301
313
|
refreshAfter: true,
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
//
|
|
3
|
+
// Bundle-ownership guard (#2834 ⑤ / ADR-0029 D8): this package's generated
|
|
4
|
+
// object-translation bundles must carry ONLY objects its extract config
|
|
5
|
+
// (`scripts/i18n-extract.config.ts`) actually imports. When an object moves to
|
|
6
|
+
// another package, its translations move with it — a leftover copy here
|
|
7
|
+
// silently DIES on the next `os i18n extract` run, taking curated translations
|
|
8
|
+
// with it (the sys_audit_log incident). This test turns that silent loss into a
|
|
9
|
+
// red build: an object present in the bundle but not in the ownership list below
|
|
10
|
+
// means either (a) the extract config gained an object — add it here — or (b) a
|
|
11
|
+
// moved/removed object's keys were left behind — remove them from the bundles
|
|
12
|
+
// (or migrate them to the owning package), then keep this list in sync.
|
|
13
|
+
//
|
|
14
|
+
// NB: this package defines more approval objects (sys_approval_approver,
|
|
15
|
+
// sys_approval_token) that the extract config deliberately does NOT translate —
|
|
16
|
+
// they are absent from both the config and the bundles, so they are correctly
|
|
17
|
+
// out of scope here.
|
|
18
|
+
|
|
19
|
+
import { describe, it, expect } from 'vitest';
|
|
20
|
+
import { enObjects } from './en.objects.generated.js';
|
|
21
|
+
|
|
22
|
+
// Objects the extract config (scripts/i18n-extract.config.ts) imports —
|
|
23
|
+
// keep the two lists in sync when adding/moving objects.
|
|
24
|
+
const OWNED_OBJECTS = new Set([
|
|
25
|
+
'sys_approval_request',
|
|
26
|
+
'sys_approval_action',
|
|
27
|
+
'sys_approval_delegation',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
describe('objects translation bundle ownership (ADR-0029 D8)', () => {
|
|
31
|
+
it('the en bundle contains no objects owned by other packages', () => {
|
|
32
|
+
const strays = Object.keys(enObjects).filter((o) => !OWNED_OBJECTS.has(o));
|
|
33
|
+
expect(
|
|
34
|
+
strays,
|
|
35
|
+
`bundle carries objects this package's extract config does not own: ${strays.join(', ')} — ` +
|
|
36
|
+
'their curated translations would be silently deleted on the next `os i18n extract`. ' +
|
|
37
|
+
'Remove the dead block from the four locale bundles, or add the object to the extract config + this list.',
|
|
38
|
+
).toEqual([]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('every owned object is present in the bundle (extract config regression)', () => {
|
|
42
|
+
const missing = [...OWNED_OBJECTS].filter((o) => !(o in enObjects));
|
|
43
|
+
expect(
|
|
44
|
+
missing,
|
|
45
|
+
`objects the extract config should emit are missing from the bundle: ${missing.join(', ')} — was an import dropped from scripts/i18n-extract.config.ts?`,
|
|
46
|
+
).toEqual([]);
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -44,7 +44,8 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
44
44
|
pending: "pending",
|
|
45
45
|
approved: "approved",
|
|
46
46
|
rejected: "rejected",
|
|
47
|
-
recalled: "recalled"
|
|
47
|
+
recalled: "recalled",
|
|
48
|
+
returned: "returned"
|
|
48
49
|
}
|
|
49
50
|
},
|
|
50
51
|
current_step: {
|
|
@@ -86,7 +87,11 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
86
87
|
},
|
|
87
88
|
_views: {
|
|
88
89
|
my_pending: {
|
|
89
|
-
label: "My Pending"
|
|
90
|
+
label: "My Pending",
|
|
91
|
+
emptyState: {
|
|
92
|
+
title: "No pending approvals",
|
|
93
|
+
message: "You're all caught up."
|
|
94
|
+
}
|
|
90
95
|
},
|
|
91
96
|
submitted_by_me: {
|
|
92
97
|
label: "I Submitted"
|
|
@@ -97,6 +102,92 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
97
102
|
all_requests: {
|
|
98
103
|
label: "All"
|
|
99
104
|
}
|
|
105
|
+
},
|
|
106
|
+
_actions: {
|
|
107
|
+
approval_approve: {
|
|
108
|
+
label: "Approve",
|
|
109
|
+
successMessage: "Approved.",
|
|
110
|
+
params: {
|
|
111
|
+
comment: {
|
|
112
|
+
label: "Comment"
|
|
113
|
+
},
|
|
114
|
+
attachments: {
|
|
115
|
+
label: "Attachments"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
approval_reject: {
|
|
120
|
+
label: "Reject",
|
|
121
|
+
confirmText: "Reject this request? A rejection is final for every approver.",
|
|
122
|
+
successMessage: "Rejected.",
|
|
123
|
+
params: {
|
|
124
|
+
comment: {
|
|
125
|
+
label: "Comment"
|
|
126
|
+
},
|
|
127
|
+
attachments: {
|
|
128
|
+
label: "Attachments"
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
approval_reassign: {
|
|
133
|
+
label: "Reassign",
|
|
134
|
+
successMessage: "Reassigned.",
|
|
135
|
+
params: {
|
|
136
|
+
to: {
|
|
137
|
+
label: "New approver",
|
|
138
|
+
helpText: "User to hand this step to"
|
|
139
|
+
},
|
|
140
|
+
comment: {
|
|
141
|
+
label: "Comment"
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
approval_send_back: {
|
|
146
|
+
label: "Send back",
|
|
147
|
+
successMessage: "Sent back for revision.",
|
|
148
|
+
params: {
|
|
149
|
+
comment: {
|
|
150
|
+
label: "Reason"
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
approval_request_info: {
|
|
155
|
+
label: "Request info",
|
|
156
|
+
successMessage: "Information requested.",
|
|
157
|
+
params: {
|
|
158
|
+
comment: {
|
|
159
|
+
label: "What do you need?"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
approval_remind: {
|
|
164
|
+
label: "Send reminder",
|
|
165
|
+
successMessage: "Reminder sent.",
|
|
166
|
+
params: {
|
|
167
|
+
comment: {
|
|
168
|
+
label: "Note"
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
approval_recall: {
|
|
173
|
+
label: "Recall",
|
|
174
|
+
confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
|
|
175
|
+
successMessage: "Recalled.",
|
|
176
|
+
params: {
|
|
177
|
+
comment: {
|
|
178
|
+
label: "Comment"
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
approval_resubmit: {
|
|
183
|
+
label: "Resubmit",
|
|
184
|
+
successMessage: "Resubmitted.",
|
|
185
|
+
params: {
|
|
186
|
+
comment: {
|
|
187
|
+
label: "What changed?"
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
100
191
|
}
|
|
101
192
|
},
|
|
102
193
|
sys_approval_action: {
|
|
@@ -128,7 +219,14 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
128
219
|
approve: "approve",
|
|
129
220
|
reject: "reject",
|
|
130
221
|
recall: "recall",
|
|
131
|
-
escalate: "escalate"
|
|
222
|
+
escalate: "escalate",
|
|
223
|
+
reassign: "reassign",
|
|
224
|
+
remind: "remind",
|
|
225
|
+
request_info: "request_info",
|
|
226
|
+
comment: "comment",
|
|
227
|
+
revise: "revise",
|
|
228
|
+
resubmit: "resubmit",
|
|
229
|
+
ooo_substitute: "ooo_substitute"
|
|
132
230
|
}
|
|
133
231
|
},
|
|
134
232
|
actor_id: {
|
|
@@ -137,13 +235,21 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
137
235
|
comment: {
|
|
138
236
|
label: "Comment"
|
|
139
237
|
},
|
|
238
|
+
attachments: {
|
|
239
|
+
label: "Attachments",
|
|
240
|
+
help: "Files supporting this action — e.g. a signed contract or evidence (#3266)."
|
|
241
|
+
},
|
|
140
242
|
created_at: {
|
|
141
243
|
label: "Created At"
|
|
142
244
|
}
|
|
143
245
|
},
|
|
144
246
|
_views: {
|
|
145
247
|
recent: {
|
|
146
|
-
label: "Recent"
|
|
248
|
+
label: "Recent",
|
|
249
|
+
emptyState: {
|
|
250
|
+
title: "No approval actions yet",
|
|
251
|
+
message: "Actions are logged automatically when approvals progress."
|
|
252
|
+
}
|
|
147
253
|
},
|
|
148
254
|
by_actor: {
|
|
149
255
|
label: "By Actor"
|
|
@@ -179,7 +285,7 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
|
|
|
179
285
|
},
|
|
180
286
|
reason: {
|
|
181
287
|
label: "Reason",
|
|
182
|
-
help: "Why the delegation exists (e.g. \"Annual leave 5/26
|
|
288
|
+
help: "Why the delegation exists (e.g. \"Annual leave 5/26–5/30\"). Recorded on the substitution audit row."
|
|
183
289
|
},
|
|
184
290
|
organization_id: {
|
|
185
291
|
label: "Organization",
|