@objectstack/plugin-approvals 16.0.0-rc.0 → 16.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objectstack/plugin-approvals",
3
- "version": "16.0.0-rc.0",
3
+ "version": "16.0.0-rc.1",
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-rc.0",
17
- "@objectstack/formula": "16.0.0-rc.0",
18
- "@objectstack/metadata-core": "16.0.0-rc.0",
19
- "@objectstack/platform-objects": "16.0.0-rc.0",
20
- "@objectstack/spec": "16.0.0-rc.0"
16
+ "@objectstack/core": "16.0.0-rc.1",
17
+ "@objectstack/formula": "16.0.0-rc.1",
18
+ "@objectstack/metadata-core": "16.0.0-rc.1",
19
+ "@objectstack/platform-objects": "16.0.0-rc.1",
20
+ "@objectstack/spec": "16.0.0-rc.1"
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-rc.0"
26
+ "@objectstack/service-automation": "16.0.0-rc.1"
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 () => {
@@ -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: 'system',
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 submitter-only levers on the current user; approver actions only on pending', () => {
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.submitter_id == ctx.user.id');
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.status == "pending"');
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: 'system',
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 (pending-approver check) — `visible` only trims
240
- // the obvious non-pending case.
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.status == "pending"',
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.status == "pending"',
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.status == "pending"',
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 on a pending request; the service is the authority on who may act,
298
- // so `visible` only trims the non-pending case (matching approve/reject).
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.status == "pending"',
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.status == "pending"',
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 `submitter_id == ctx.user.id`
333
- // the current user is exposed via the console's predicate scope. The
334
- // service re-checks ownership; the predicate keeps a non-submitter from ever
335
- // seeing a button they cannot use.
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.submitter_id == ctx.user.id',
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.submitter_id == ctx.user.id',
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.submitter_id == ctx.user.id',
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: 'system',
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)