@objectstack/plugin-approvals 16.0.0-rc.0 → 16.0.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 +220 -0
- package/dist/index.d.mts +44 -22
- package/dist/index.d.ts +44 -22
- package/dist/index.js +51 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +51 -21
- package/dist/index.mjs.map +1 -1
- package/package.json +7 -7
- package/src/approval-service.test.ts +27 -0
- package/src/approval-service.ts +22 -0
- package/src/sys-approval-approver.object.ts +1 -1
- package/src/sys-approval-request.object.test.ts +14 -5
- package/src/sys-approval-request.object.ts +26 -17
- package/src/sys-approval-token.object.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@objectstack/plugin-approvals",
|
|
3
|
-
"version": "16.0.0
|
|
3
|
+
"version": "16.0.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"description": "Multi-step approval engine for ObjectStack — sys_approval_process + sys_approval_request + sys_approval_action + IApprovalService.",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -13,17 +13,17 @@
|
|
|
13
13
|
}
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@objectstack/core": "16.0.0
|
|
17
|
-
"@objectstack/formula": "16.0.0
|
|
18
|
-
"@objectstack/metadata-core": "16.0.0
|
|
19
|
-
"@objectstack/platform-objects": "16.0.0
|
|
20
|
-
"@objectstack/spec": "16.0.0
|
|
16
|
+
"@objectstack/core": "16.0.0",
|
|
17
|
+
"@objectstack/formula": "16.0.0",
|
|
18
|
+
"@objectstack/metadata-core": "16.0.0",
|
|
19
|
+
"@objectstack/platform-objects": "16.0.0",
|
|
20
|
+
"@objectstack/spec": "16.0.0"
|
|
21
21
|
},
|
|
22
22
|
"devDependencies": {
|
|
23
23
|
"@types/node": "^26.1.1",
|
|
24
24
|
"typescript": "^6.0.3",
|
|
25
25
|
"vitest": "^4.1.10",
|
|
26
|
-
"@objectstack/service-automation": "16.0.0
|
|
26
|
+
"@objectstack/service-automation": "16.0.0"
|
|
27
27
|
},
|
|
28
28
|
"keywords": [
|
|
29
29
|
"objectstack",
|
|
@@ -414,6 +414,33 @@ describe('ApprovalService (node era)', () => {
|
|
|
414
414
|
expect(await svc.getRequest('nope', SYS)).toBeNull();
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
+
// ── viewer capability (#3310) ───────────────────────────────────
|
|
418
|
+
it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
|
|
419
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
|
|
420
|
+
const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
421
|
+
expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false });
|
|
422
|
+
const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
|
|
423
|
+
expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true });
|
|
424
|
+
const asOther = await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any);
|
|
425
|
+
expect(asOther!.viewer).toEqual({ can_act: false, is_submitter: false });
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
|
|
429
|
+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
430
|
+
await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS); // → approved
|
|
431
|
+
const after = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
|
|
432
|
+
expect(after!.status).toBe('approved');
|
|
433
|
+
expect(after!.viewer!.can_act).toBe(false);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('listRequests: attaches viewer to every row from the caller context', async () => {
|
|
437
|
+
await svc.openNodeRequest(openInput(['u9']), CTX);
|
|
438
|
+
const rows = await svc.listRequests({ status: 'pending' }, { userId: 'u9', tenantId: 't1' } as any);
|
|
439
|
+
expect(rows.length).toBeGreaterThan(0);
|
|
440
|
+
expect(rows.every(r => r.viewer != null)).toBe(true);
|
|
441
|
+
expect(rows[0].viewer!.can_act).toBe(true);
|
|
442
|
+
});
|
|
443
|
+
|
|
417
444
|
// ── recall ──────────────────────────────────────────────────────
|
|
418
445
|
|
|
419
446
|
it('recall: submitter withdraws a pending request', async () => {
|
package/src/approval-service.ts
CHANGED
|
@@ -2174,6 +2174,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
2174
2174
|
const rows = await this.engine.find('sys_approval_request', findOpts);
|
|
2175
2175
|
const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
|
|
2176
2176
|
await this.enrichRows(list);
|
|
2177
|
+
this.attachViewers(list, context);
|
|
2177
2178
|
return list;
|
|
2178
2179
|
}
|
|
2179
2180
|
|
|
@@ -2221,6 +2222,7 @@ export class ApprovalService implements IApprovalService {
|
|
|
2221
2222
|
await this.enrichRows([row]);
|
|
2222
2223
|
await this.attachFlowSteps(row);
|
|
2223
2224
|
await this.attachDecisionProgress(row, rows[0]);
|
|
2225
|
+
this.attachViewers([row], context);
|
|
2224
2226
|
return row;
|
|
2225
2227
|
}
|
|
2226
2228
|
|
|
@@ -2270,6 +2272,26 @@ export class ApprovalService implements IApprovalService {
|
|
|
2270
2272
|
} catch { /* display-only enrichment */ }
|
|
2271
2273
|
}
|
|
2272
2274
|
|
|
2275
|
+
/**
|
|
2276
|
+
* Attach the per-viewer capability block (#3310) from the caller's context.
|
|
2277
|
+
* `can_act` mirrors the exact authorization the decision methods enforce — the
|
|
2278
|
+
* caller's user id is in the resolved `pending_approvers` while the request is
|
|
2279
|
+
* still `pending` (position/team/manager approvers are already resolved to
|
|
2280
|
+
* concrete user ids at open time, so a plain membership test is faithful).
|
|
2281
|
+
* `is_submitter` is a straight owner check. System/tokenless contexts get a
|
|
2282
|
+
* both-false block. Cheap + synchronous — safe on list reads.
|
|
2283
|
+
*/
|
|
2284
|
+
private attachViewers(rows: ApprovalRequestRow[], context: SharingExecutionContext): void {
|
|
2285
|
+
const uid = (context as any)?.userId != null ? String((context as any).userId) : null;
|
|
2286
|
+
for (const row of rows) {
|
|
2287
|
+
const pending = row.pending_approvers ?? [];
|
|
2288
|
+
(row as any).viewer = {
|
|
2289
|
+
can_act: row.status === 'pending' && !!uid && pending.includes(uid),
|
|
2290
|
+
is_submitter: !!uid && row.submitter_id != null && String(row.submitter_id) === uid,
|
|
2291
|
+
};
|
|
2292
|
+
}
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2273
2295
|
/**
|
|
2274
2296
|
* Derive approval-step progress from the owning flow's graph (single-read
|
|
2275
2297
|
* enrichment only — list reads skip it). Walks from the start node
|
|
@@ -30,7 +30,7 @@ export const SysApprovalApprover = ObjectSchema.create({
|
|
|
30
30
|
pluralLabel: 'Approval Approvers',
|
|
31
31
|
icon: 'users',
|
|
32
32
|
isSystem: true,
|
|
33
|
-
managedBy: '
|
|
33
|
+
managedBy: 'engine-owned',
|
|
34
34
|
description: 'Normalized pending-approver rows for indexed inbox queries',
|
|
35
35
|
displayNameField: 'id',
|
|
36
36
|
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
@@ -61,15 +61,16 @@ describe('sys_approval_request declared actions', () => {
|
|
|
61
61
|
}
|
|
62
62
|
});
|
|
63
63
|
|
|
64
|
-
it('gates
|
|
64
|
+
it('gates on the server-computed viewer block (#3310): approver actions on can_act, submitter levers on is_submitter', () => {
|
|
65
65
|
for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
|
|
66
|
-
expect(vis(name)).toContain('record.
|
|
66
|
+
expect(vis(name)).toContain('record.viewer.is_submitter');
|
|
67
|
+
expect(vis(name)).not.toContain('can_act');
|
|
67
68
|
}
|
|
68
|
-
// Approver-side actions defer who-can-act to the service; they only trim the
|
|
69
|
-
// non-pending case in the UI.
|
|
70
69
|
for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
|
|
71
|
-
expect(vis(name)).toContain('record.
|
|
70
|
+
expect(vis(name)).toContain('record.viewer.can_act');
|
|
71
|
+
// who-can-act is server-derived, never a client identity guess
|
|
72
72
|
expect(vis(name)).not.toContain('ctx.user.id');
|
|
73
|
+
expect(vis(name)).not.toContain('is_submitter');
|
|
73
74
|
}
|
|
74
75
|
});
|
|
75
76
|
|
|
@@ -91,4 +92,12 @@ describe('sys_approval_request declared actions', () => {
|
|
|
91
92
|
expect(c.required ?? false).toBe(false);
|
|
92
93
|
}
|
|
93
94
|
});
|
|
95
|
+
|
|
96
|
+
it('approve/reject collect optional multi-file decision attachments', () => {
|
|
97
|
+
for (const name of ['approval_approve', 'approval_reject']) {
|
|
98
|
+
const att = byName(name).params.find((p: any) => p.name === 'attachments');
|
|
99
|
+
expect(att, `${name}.attachments`).toMatchObject({ type: 'file', multiple: true });
|
|
100
|
+
expect(att.required ?? false).toBe(false);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
94
103
|
});
|
|
@@ -28,7 +28,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
28
28
|
pluralLabel: 'Approval Requests',
|
|
29
29
|
icon: 'inbox',
|
|
30
30
|
isSystem: true,
|
|
31
|
-
managedBy: '
|
|
31
|
+
managedBy: 'engine-owned',
|
|
32
32
|
description: 'Live approval instance tracked per submission',
|
|
33
33
|
displayNameField: 'id',
|
|
34
34
|
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|
|
@@ -236,8 +236,12 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
236
236
|
// (and their params) ship as metadata, not as hand-written buttons. Each
|
|
237
237
|
// targets the existing approvals REST route; `{id}` resolves from the row
|
|
238
238
|
// and `actorId` defaults to the caller server-side. The service remains the
|
|
239
|
-
// authority on who may act
|
|
240
|
-
//
|
|
239
|
+
// authority on who may act; `visible` gates on the server-computed
|
|
240
|
+
// per-viewer block (#3310): approver actions on `record.viewer.can_act`
|
|
241
|
+
// (the caller is a current pending approver — same check the service
|
|
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.
|
|
241
245
|
actions: [
|
|
242
246
|
{
|
|
243
247
|
name: 'approval_approve',
|
|
@@ -248,8 +252,12 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
248
252
|
target: '/api/v1/approvals/requests/{id}/approve',
|
|
249
253
|
params: [
|
|
250
254
|
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
255
|
+
// Decision attachments (#3266). The console renders `type:'file'` params
|
|
256
|
+
// through the shared upload widget and POSTs the resolved `attachments:
|
|
257
|
+
// string[]`; the decision route persists them on `sys_approval_action`.
|
|
258
|
+
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
|
|
251
259
|
],
|
|
252
|
-
visible: 'record.
|
|
260
|
+
visible: 'record.viewer.can_act',
|
|
253
261
|
locations: ['record_section', 'list_item'],
|
|
254
262
|
successMessage: 'Approved.',
|
|
255
263
|
refreshAfter: true,
|
|
@@ -263,8 +271,9 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
263
271
|
target: '/api/v1/approvals/requests/{id}/reject',
|
|
264
272
|
params: [
|
|
265
273
|
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
274
|
+
{ name: 'attachments', label: 'Attachments', type: 'file', multiple: true, required: false },
|
|
266
275
|
],
|
|
267
|
-
visible: 'record.
|
|
276
|
+
visible: 'record.viewer.can_act',
|
|
268
277
|
confirmText: 'Reject this request? A rejection is final for every approver.',
|
|
269
278
|
locations: ['record_section', 'list_item'],
|
|
270
279
|
successMessage: 'Rejected.',
|
|
@@ -286,7 +295,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
286
295
|
{ field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
|
|
287
296
|
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
|
|
288
297
|
],
|
|
289
|
-
visible: 'record.
|
|
298
|
+
visible: 'record.viewer.can_act',
|
|
290
299
|
locations: ['record_section'],
|
|
291
300
|
successMessage: 'Reassigned.',
|
|
292
301
|
refreshAfter: true,
|
|
@@ -294,8 +303,8 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
294
303
|
|
|
295
304
|
// ── Approver secondary decisions ────────────────────────────────
|
|
296
305
|
// Send back for revision / request more info (ADR-0044). Both are approver
|
|
297
|
-
// actions
|
|
298
|
-
//
|
|
306
|
+
// actions, so `visible` gates on `record.viewer.can_act` (a current pending
|
|
307
|
+
// approver) — same as approve/reject. The service stays the authority.
|
|
299
308
|
{
|
|
300
309
|
name: 'approval_send_back',
|
|
301
310
|
label: 'Send back',
|
|
@@ -306,7 +315,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
306
315
|
params: [
|
|
307
316
|
{ name: 'comment', label: 'Reason', type: 'textarea', required: false },
|
|
308
317
|
],
|
|
309
|
-
visible: 'record.
|
|
318
|
+
visible: 'record.viewer.can_act',
|
|
310
319
|
locations: ['record_section'],
|
|
311
320
|
successMessage: 'Sent back for revision.',
|
|
312
321
|
refreshAfter: true,
|
|
@@ -321,7 +330,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
321
330
|
params: [
|
|
322
331
|
{ name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
|
|
323
332
|
],
|
|
324
|
-
visible: 'record.
|
|
333
|
+
visible: 'record.viewer.can_act',
|
|
325
334
|
locations: ['record_section'],
|
|
326
335
|
successMessage: 'Information requested.',
|
|
327
336
|
refreshAfter: true,
|
|
@@ -329,10 +338,10 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
329
338
|
|
|
330
339
|
// ── Submitter continuity actions ────────────────────────────────
|
|
331
340
|
// Remind / recall (pending) and resubmit / recall (returned). These are the
|
|
332
|
-
// submitter's own levers, so `visible` gates on `
|
|
333
|
-
//
|
|
334
|
-
//
|
|
335
|
-
//
|
|
341
|
+
// submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
|
|
342
|
+
// (server-computed on the current viewer). The service re-checks ownership;
|
|
343
|
+
// the predicate keeps a non-submitter from ever seeing a button they cannot
|
|
344
|
+
// use.
|
|
336
345
|
{
|
|
337
346
|
name: 'approval_remind',
|
|
338
347
|
label: 'Send reminder',
|
|
@@ -343,7 +352,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
343
352
|
params: [
|
|
344
353
|
{ name: 'comment', label: 'Note', type: 'textarea', required: false },
|
|
345
354
|
],
|
|
346
|
-
visible: 'record.status == "pending" && record.
|
|
355
|
+
visible: 'record.status == "pending" && record.viewer.is_submitter',
|
|
347
356
|
locations: ['record_section'],
|
|
348
357
|
successMessage: 'Reminder sent.',
|
|
349
358
|
refreshAfter: true,
|
|
@@ -360,7 +369,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
360
369
|
],
|
|
361
370
|
// Recall applies while the request is live for the submitter — pending
|
|
362
371
|
// (withdraw) or returned (abandon the revision instead of resubmitting).
|
|
363
|
-
visible: '(record.status == "pending" || record.status == "returned") && record.
|
|
372
|
+
visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
|
|
364
373
|
confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
|
|
365
374
|
locations: ['record_section'],
|
|
366
375
|
successMessage: 'Recalled.',
|
|
@@ -376,7 +385,7 @@ export const SysApprovalRequest = ObjectSchema.create({
|
|
|
376
385
|
params: [
|
|
377
386
|
{ name: 'comment', label: 'What changed?', type: 'textarea', required: false },
|
|
378
387
|
],
|
|
379
|
-
visible: 'record.status == "returned" && record.
|
|
388
|
+
visible: 'record.status == "returned" && record.viewer.is_submitter',
|
|
380
389
|
locations: ['record_section'],
|
|
381
390
|
successMessage: 'Resubmitted.',
|
|
382
391
|
refreshAfter: true,
|
|
@@ -19,7 +19,7 @@ export const SysApprovalToken = ObjectSchema.create({
|
|
|
19
19
|
pluralLabel: 'Approval Action Tokens',
|
|
20
20
|
icon: 'key',
|
|
21
21
|
isSystem: true,
|
|
22
|
-
managedBy: '
|
|
22
|
+
managedBy: 'engine-owned',
|
|
23
23
|
description: 'Single-use tokens behind actionable approval links',
|
|
24
24
|
displayNameField: 'id',
|
|
25
25
|
nameField: 'id', // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
|