@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
|
@@ -20,7 +20,14 @@ import { registerApprovalNode } from './approval-node.js';
|
|
|
20
20
|
import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js';
|
|
21
21
|
|
|
22
22
|
const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any;
|
|
23
|
-
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The signed-in caller. An approval action is recorded against the
|
|
26
|
+
* AUTHENTICATED caller (#3800), so each call below presents the context of the
|
|
27
|
+
* person it names — an identity-less context can no longer act by naming one.
|
|
28
|
+
*/
|
|
29
|
+
const asUser = (userId: string) =>
|
|
30
|
+
({ isSystem: false, userId, positions: [], permissions: [] }) as any;
|
|
24
31
|
|
|
25
32
|
const noopLogger = { info() {}, warn() {}, error() {}, debug() {} };
|
|
26
33
|
|
|
@@ -156,7 +163,7 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
156
163
|
registerReviseFlow();
|
|
157
164
|
const { runId, req } = await startFlow();
|
|
158
165
|
|
|
159
|
-
const sent = await service.sendBack(req.id, { actorId: 'u1', comment: 'fix the totals' },
|
|
166
|
+
const sent = await service.sendBack(req.id, { actorId: 'u1', comment: 'fix the totals' }, asUser('u1'));
|
|
160
167
|
expect(sent.resumed).toBe(true);
|
|
161
168
|
expect(sent.autoRejected).toBeUndefined();
|
|
162
169
|
expect(sent.request.status).toBe('returned');
|
|
@@ -166,7 +173,7 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
166
173
|
expect(automation.listSuspendedRuns()).toMatchObject([{ runId, nodeId: 'wait_revision' }]);
|
|
167
174
|
expect(await actionsOf(req.id)).toEqual(['submit', 'revise']);
|
|
168
175
|
|
|
169
|
-
const re = await service.resubmit(req.id, { actorId: 'submitter', comment: 'totals fixed' },
|
|
176
|
+
const re = await service.resubmit(req.id, { actorId: 'submitter', comment: 'totals fixed' }, asUser('submitter'));
|
|
170
177
|
expect(re.resumed).toBe(true);
|
|
171
178
|
expect(await actionsOf(req.id)).toEqual(['submit', 'revise', 'resubmit']);
|
|
172
179
|
|
|
@@ -189,13 +196,13 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
189
196
|
const { req } = await startFlow();
|
|
190
197
|
expect((await service.getRequest(req.id, SYSTEM_CTX))?.round).toBeUndefined(); // round 1
|
|
191
198
|
|
|
192
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
193
|
-
await service.resubmit(req.id, { actorId: 'submitter' },
|
|
199
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
200
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
194
201
|
const round2 = await pendingReq();
|
|
195
202
|
expect((await service.getRequest(round2.id, SYSTEM_CTX))?.round).toBe(2);
|
|
196
203
|
|
|
197
|
-
await service.sendBack(round2.id, { actorId: 'u1' },
|
|
198
|
-
await service.resubmit(round2.id, { actorId: 'submitter' },
|
|
204
|
+
await service.sendBack(round2.id, { actorId: 'u1' }, asUser('u1'));
|
|
205
|
+
await service.resubmit(round2.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
199
206
|
const round3 = await pendingReq();
|
|
200
207
|
expect((await service.getRequest(round3.id, SYSTEM_CTX))?.round).toBe(3);
|
|
201
208
|
});
|
|
@@ -205,12 +212,12 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
205
212
|
const { req } = await startFlow();
|
|
206
213
|
|
|
207
214
|
// Send-back #1 fits the budget.
|
|
208
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
209
|
-
await service.resubmit(req.id, { actorId: 'submitter' },
|
|
215
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
216
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
210
217
|
const round2 = await pendingReq();
|
|
211
218
|
|
|
212
219
|
// Send-back #2 exceeds it → auto-reject, flow takes the reject branch.
|
|
213
|
-
const out = await service.sendBack(round2.id, { actorId: 'u1', comment: 'still wrong' },
|
|
220
|
+
const out = await service.sendBack(round2.id, { actorId: 'u1', comment: 'still wrong' }, asUser('u1'));
|
|
214
221
|
expect(out.autoRejected).toBe(true);
|
|
215
222
|
expect(out.resumed).toBe(true);
|
|
216
223
|
expect(out.request.status).toBe('rejected');
|
|
@@ -224,7 +231,7 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
224
231
|
it('maxRevisions 0 disables send-back (immediate auto-reject)', async () => {
|
|
225
232
|
registerReviseFlow({ maxRevisions: 0 });
|
|
226
233
|
const { req } = await startFlow();
|
|
227
|
-
const out = await service.sendBack(req.id, { actorId: 'u1' },
|
|
234
|
+
const out = await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
228
235
|
expect(out.autoRejected).toBe(true);
|
|
229
236
|
expect(marks).toEqual(['on_rejected']);
|
|
230
237
|
});
|
|
@@ -246,7 +253,7 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
246
253
|
await automation.execute('no_revise', { object: 'fin_expense', record: { id: 'x2' }, userId: 'submitter' });
|
|
247
254
|
const req = await pendingReq();
|
|
248
255
|
|
|
249
|
-
await expect(service.sendBack(req.id, { actorId: 'u1' },
|
|
256
|
+
await expect(service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/no 'revise' out-edge/);
|
|
250
257
|
// Nothing moved: still pending, no revise audit row.
|
|
251
258
|
expect((await fake.find('sys_approval_request', { where: { id: req.id } }))[0].status).toBe('pending');
|
|
252
259
|
expect(await actionsOf(req.id)).toEqual(['submit']);
|
|
@@ -264,10 +271,10 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
264
271
|
expect(first.finalized).toBe(false);
|
|
265
272
|
|
|
266
273
|
// u2 sends back instead: finalizes despite u1's earlier approval.
|
|
267
|
-
const sent = await service.sendBack(req.id, { actorId: 'u2', comment: 'rework' },
|
|
274
|
+
const sent = await service.sendBack(req.id, { actorId: 'u2', comment: 'rework' }, asUser('u2'));
|
|
268
275
|
expect(sent.request.status).toBe('returned');
|
|
269
276
|
|
|
270
|
-
await service.resubmit(req.id, { actorId: 'submitter' },
|
|
277
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
271
278
|
const round2 = await pendingReq();
|
|
272
279
|
// Fresh slate: BOTH approvers pending again — prior approvals are stale.
|
|
273
280
|
expect((round2.pending_approvers as string).split(',').sort()).toEqual(['u1', 'u2']);
|
|
@@ -292,21 +299,21 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
292
299
|
});
|
|
293
300
|
|
|
294
301
|
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // pending → locked
|
|
295
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
302
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
296
303
|
await expect(editAttempt()).resolves.toBeUndefined(); // returned → unlocked
|
|
297
|
-
await service.resubmit(req.id, { actorId: 'submitter' },
|
|
304
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
298
305
|
await expect(editAttempt()).rejects.toThrow(/RECORD_LOCKED/); // round 2 pending → re-locked
|
|
299
306
|
});
|
|
300
307
|
|
|
301
308
|
it('recall crossing the revise window cancels the run (returned → recalled)', async () => {
|
|
302
309
|
registerReviseFlow();
|
|
303
310
|
const { runId, req } = await startFlow();
|
|
304
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
311
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
305
312
|
|
|
306
313
|
// Only the submitter may abandon the revision.
|
|
307
|
-
await expect(service.recall(req.id, { actorId: 'u1' },
|
|
314
|
+
await expect(service.recall(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
308
315
|
|
|
309
|
-
const out = await service.recall(req.id, { actorId: 'submitter' },
|
|
316
|
+
const out = await service.recall(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
310
317
|
expect(out.request.status).toBe('recalled');
|
|
311
318
|
expect(out.resumed).toBe(false);
|
|
312
319
|
// The run was terminally cancelled, not resumed down any branch.
|
|
@@ -317,13 +324,13 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
317
324
|
expect(log.id).toBe(runId);
|
|
318
325
|
|
|
319
326
|
// The window is closed: resubmit is no longer possible.
|
|
320
|
-
await expect(service.resubmit(req.id, { actorId: 'submitter' },
|
|
327
|
+
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
|
|
321
328
|
});
|
|
322
329
|
|
|
323
330
|
it('refuses resubmit while another pending request collides on the record (run stays resumable)', async () => {
|
|
324
331
|
registerReviseFlow();
|
|
325
332
|
const { runId, req } = await startFlow();
|
|
326
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
333
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
327
334
|
|
|
328
335
|
// Simulate a record-change trigger re-firing off an edit made inside the
|
|
329
336
|
// revise window: a second, unrelated run opened its own pending request.
|
|
@@ -334,38 +341,38 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
334
341
|
created_at: new Date().toISOString(),
|
|
335
342
|
});
|
|
336
343
|
|
|
337
|
-
await expect(service.resubmit(req.id, { actorId: 'submitter' },
|
|
344
|
+
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/DUPLICATE_REQUEST/);
|
|
338
345
|
// The refusal happened BEFORE the suspension was consumed — clearing the
|
|
339
346
|
// collision makes the same resubmit succeed.
|
|
340
347
|
expect(automation.listSuspendedRuns().some(r => r.runId === runId)).toBe(true);
|
|
341
348
|
await fake.delete('sys_approval_request', { where: { id: 'areq_collider' } });
|
|
342
|
-
const re = await service.resubmit(req.id, { actorId: 'submitter' },
|
|
349
|
+
const re = await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
343
350
|
expect(re.resumed).toBe(true);
|
|
344
351
|
});
|
|
345
352
|
|
|
346
353
|
it('a superseded returned request can neither resubmit again nor be recalled', async () => {
|
|
347
354
|
registerReviseFlow();
|
|
348
355
|
const { req } = await startFlow();
|
|
349
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
350
|
-
await service.resubmit(req.id, { actorId: 'submitter' },
|
|
356
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
357
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
351
358
|
|
|
352
359
|
// Round 2 is the live frontier; the round-1 row is history.
|
|
353
|
-
await expect(service.resubmit(req.id, { actorId: 'submitter' },
|
|
354
|
-
await expect(service.recall(req.id, { actorId: 'submitter' },
|
|
360
|
+
await expect(service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/supersedes/);
|
|
361
|
+
await expect(service.recall(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/supersedes/);
|
|
355
362
|
});
|
|
356
363
|
|
|
357
364
|
it('enforces the actor matrix: only pending approvers send back, only the submitter resubmits', async () => {
|
|
358
365
|
registerReviseFlow();
|
|
359
366
|
const { req } = await startFlow();
|
|
360
367
|
|
|
361
|
-
await expect(service.sendBack(req.id, { actorId: 'intruder' },
|
|
362
|
-
await expect(service.sendBack(req.id, { actorId: 'submitter' },
|
|
368
|
+
await expect(service.sendBack(req.id, { actorId: 'intruder' }, asUser('intruder'))).rejects.toThrow(/FORBIDDEN/);
|
|
369
|
+
await expect(service.sendBack(req.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/FORBIDDEN/);
|
|
363
370
|
|
|
364
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
365
|
-
await expect(service.resubmit(req.id, { actorId: 'u1' },
|
|
371
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
372
|
+
await expect(service.resubmit(req.id, { actorId: 'u1' }, asUser('u1'))).rejects.toThrow(/FORBIDDEN/);
|
|
366
373
|
// Resubmit only applies to returned requests — a pending one rejects.
|
|
367
374
|
const fresh = await startFlowSecondRecord();
|
|
368
|
-
await expect(service.resubmit(fresh.id, { actorId: 'submitter' },
|
|
375
|
+
await expect(service.resubmit(fresh.id, { actorId: 'submitter' }, asUser('submitter'))).rejects.toThrow(/INVALID_STATE/);
|
|
369
376
|
});
|
|
370
377
|
|
|
371
378
|
/** A second record's pending request, to probe resubmit-on-pending. */
|
|
@@ -403,9 +410,9 @@ describe('Send back for revision (ADR-0044)', () => {
|
|
|
403
410
|
const mirror = async () => (await fake.find('fin_expense', { where: { id: 'm1' } }))[0].approval_status;
|
|
404
411
|
|
|
405
412
|
expect(await mirror()).toBe('pending');
|
|
406
|
-
await service.sendBack(req.id, { actorId: 'u1' },
|
|
413
|
+
await service.sendBack(req.id, { actorId: 'u1' }, asUser('u1'));
|
|
407
414
|
expect(await mirror()).toBe('returned');
|
|
408
|
-
await service.resubmit(req.id, { actorId: 'submitter' },
|
|
415
|
+
await service.resubmit(req.id, { actorId: 'submitter' }, asUser('submitter'));
|
|
409
416
|
expect(await mirror()).toBe('pending'); // round 2 re-mirrors
|
|
410
417
|
});
|
|
411
418
|
});
|