@objectstack/plugin-approvals 16.0.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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +11 -11
  2. package/CHANGELOG.md +1037 -0
  3. package/dist/index.d.mts +650 -535
  4. package/dist/index.d.ts +650 -535
  5. package/dist/index.js +1713 -207
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1711 -197
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -7
  10. package/scripts/i18n-extract.config.ts +6 -1
  11. package/src/approval-actor-impersonation.test.ts +330 -0
  12. package/src/approval-node.test.ts +160 -0
  13. package/src/approval-node.ts +57 -0
  14. package/src/approval-revise.test.ts +41 -34
  15. package/src/approval-service.test.ts +1408 -40
  16. package/src/approval-service.ts +1364 -107
  17. package/src/approvals-plugin.ts +36 -5
  18. package/src/approver-cross-org.integration.test.ts +206 -0
  19. package/src/approver-org-scope.test.ts +201 -0
  20. package/src/approver-org-scope.ts +261 -0
  21. package/src/index.ts +3 -0
  22. package/src/lifecycle-hooks.ts +22 -0
  23. package/src/record-lock-schedule-run.integration.test.ts +206 -0
  24. package/src/status-mirror-cascade.integration.test.ts +224 -0
  25. package/src/sys-approval-action.object.ts +9 -0
  26. package/src/sys-approval-delegation.object.test.ts +42 -0
  27. package/src/sys-approval-delegation.object.ts +3 -3
  28. package/src/sys-approval-request.object.test.ts +13 -0
  29. package/src/sys-approval-request.object.ts +17 -5
  30. package/src/translations/bundle-ownership.test.ts +48 -0
  31. package/src/translations/en.objects.generated.ts +111 -5
  32. package/src/translations/es-ES.objects.generated.ts +111 -5
  33. package/src/translations/ja-JP.objects.generated.ts +111 -5
  34. 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
- apiMethods: ['get', 'list', 'create', 'update', 'delete'],
139
- trash: true,
140
- mru: false,
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`. `viewer` is attached by
244
- // getRequest/listRequests; where it is absent the predicate fails closed.
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\u20135/30\"). Recorded on the substitution audit row."
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",