Skip to content

Commit 82a6fb0

Browse files
os-zhuangclaude
andauthored
feat(approvals): declare full decision/continuity action set + wire revise/resubmit routes (#3300)
Backend half of retiring the approvals inbox's hand-written buttons (objectui#2678 P2-4 follow-up). The console's generic action runtime already renders `sys_approval_request`'s declared actions anywhere the object is surfaced; this completes the declared set so the inbox no longer needs a bespoke button per capability. New declared actions on sys_approval_request (all `type:'api'`, POST, {id}- interpolated, refreshAfter): - approval_send_back → /revise (approver; status==pending) - approval_request_info → /request-info (approver; comment required) - approval_remind → /remind (submitter-gated) - approval_recall → /recall (submitter-gated; pending|returned) - approval_resubmit → /resubmit (submitter-gated; returned) Submitter-only levers gate on `record.submitter_id == ctx.user.id` (the console exposes the current user in the predicate scope); approver actions gate only on `record.status == "pending"` and defer who-can-act to the service, matching the existing approve/reject precedent. approval_reassign's `to` param becomes field-backed on `submitter_id` so the dialog resolves its `sys_user` lookup config and renders a real user picker (keyed as `to`), replacing the free-text user-id box. Also registers the missing `/revise` and `/resubmit` REST routes: the console has been POSTing to them for send-back/resubmit (ADR-0044) but rest-server never wired them, so both 404'd. They now dispatch to the existing ApprovalService.sendBack / resubmit with the standard approval error mapping. Contract test pins the action set, that every target hits a registered route verb, and the submitter-vs-approver gating. Claude-Session: https://claude.ai/code/session_016ypkQikZ55oWUHUnMebwXA Co-authored-by: Claude <noreply@anthropic.com>
1 parent a140ff0 commit 82a6fb0

3 files changed

Lines changed: 241 additions & 1 deletion

File tree

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Contract test for the server-declared decision actions on
5+
* `sys_approval_request` (objectui#2678 P2-4 / retire-hardcoded-buttons).
6+
*
7+
* The console's generic action runtime renders + executes these wherever the
8+
* object is surfaced (the approvals inbox included), so the inbox no longer
9+
* hand-writes a button per capability. That only holds if the declared set
10+
* stays faithful to the REST routes it targets and to the who-can-act gating.
11+
* This pins both:
12+
*
13+
* • every `type:'api'` target resolves `{id}` and points at a route that the
14+
* REST server actually registers (approve/reject/reassign/recall/remind/
15+
* request-info/revise/resubmit) — a typo'd verb would 404 silently in the UI;
16+
* • submitter-only levers (remind/recall/resubmit) gate on
17+
* `submitter_id == ctx.user.id` so a non-submitter never sees them.
18+
*/
19+
20+
import { describe, it, expect } from 'vitest';
21+
import { SysApprovalRequest } from './sys-approval-request.object.js';
22+
23+
const actions = (SysApprovalRequest as any).actions as any[];
24+
const byName = (n: string) => actions.find((a) => a.name === n);
25+
/** `ObjectSchema.create` normalizes `visible` strings into a
26+
* `{ dialect: 'cel', source }` envelope — read the source for substring asserts. */
27+
const vis = (n: string): string => {
28+
const v = byName(n).visible;
29+
return typeof v === 'string' ? v : String(v?.source ?? '');
30+
};
31+
32+
/** Verbs the REST server registers under `/api/v1/approvals/requests/:id/*`. */
33+
const ROUTE_VERBS = new Set([
34+
'approve', 'reject', 'reassign', 'recall', 'remind', 'request-info', 'revise', 'resubmit',
35+
]);
36+
37+
describe('sys_approval_request declared actions', () => {
38+
it('declares the full decision + continuity set', () => {
39+
expect(actions.map((a) => a.name).sort()).toEqual(
40+
[
41+
'approval_approve',
42+
'approval_recall',
43+
'approval_reassign',
44+
'approval_reject',
45+
'approval_remind',
46+
'approval_request_info',
47+
'approval_resubmit',
48+
'approval_send_back',
49+
].sort(),
50+
);
51+
});
52+
53+
it('every api target points at a registered approvals route verb and injects {id}', () => {
54+
for (const a of actions) {
55+
expect(a.type).toBe('api');
56+
expect(a.method).toBe('POST');
57+
const m = /^\/api\/v1\/approvals\/requests\/\{id\}\/([a-z-]+)$/.exec(a.target);
58+
expect(m, `${a.name} target ${a.target}`).not.toBeNull();
59+
expect(ROUTE_VERBS.has(m![1]), `${a.name}${m![1]}`).toBe(true);
60+
expect(a.refreshAfter).toBe(true);
61+
}
62+
});
63+
64+
it('gates submitter-only levers on the current user; approver actions only on pending', () => {
65+
for (const name of ['approval_remind', 'approval_recall', 'approval_resubmit']) {
66+
expect(vis(name)).toContain('record.submitter_id == ctx.user.id');
67+
}
68+
// Approver-side actions defer who-can-act to the service; they only trim the
69+
// non-pending case in the UI.
70+
for (const name of ['approval_approve', 'approval_reject', 'approval_send_back', 'approval_request_info', 'approval_reassign']) {
71+
expect(vis(name)).toContain('record.status == "pending"');
72+
expect(vis(name)).not.toContain('ctx.user.id');
73+
}
74+
});
75+
76+
it('recall stays available while a returned request is still the submitter\'s to abandon', () => {
77+
expect(vis('approval_recall')).toContain('record.status == "returned"');
78+
expect(byName('approval_recall').confirmText).toBeTruthy();
79+
});
80+
81+
it('reassign collects the new approver via a field-backed sys_user picker keyed as `to`', () => {
82+
const toParam = byName('approval_reassign').params.find((p: any) => p.name === 'to');
83+
expect(toParam).toMatchObject({ field: 'submitter_id', name: 'to', required: true });
84+
});
85+
86+
it('request-info requires a comment; other params stay optional', () => {
87+
const ri = byName('approval_request_info').params.find((p: any) => p.name === 'comment');
88+
expect(ri.required).toBe(true);
89+
for (const name of ['approval_send_back', 'approval_remind', 'approval_recall', 'approval_resubmit']) {
90+
const c = byName(name).params.find((p: any) => p.name === 'comment');
91+
expect(c.required ?? false).toBe(false);
92+
}
93+
});
94+
});

packages/plugins/plugin-approvals/src/sys-approval-request.object.ts

Lines changed: 96 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,13 +278,108 @@ export const SysApprovalRequest = ObjectSchema.create({
278278
method: 'POST',
279279
target: '/api/v1/approvals/requests/{id}/reassign',
280280
params: [
281-
{ name: 'to', label: 'New approver (user id)', type: 'text', required: true },
281+
// Field-backed on `submitter_id` (the object's only `sys_user` lookup):
282+
// the console resolves its lookup config (`reference_to: sys_user`) so the
283+
// dialog renders a real user picker, while `name: 'to'` overrides the
284+
// request-body key to the `to` the reassign route expects. This is a
285+
// config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
286+
{ field: 'submitter_id', name: 'to', label: 'New approver', required: true, helpText: 'User to hand this step to' },
282287
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
283288
],
284289
visible: 'record.status == "pending"',
285290
locations: ['record_section'],
286291
successMessage: 'Reassigned.',
287292
refreshAfter: true,
288293
},
294+
295+
// ── Approver secondary decisions ────────────────────────────────
296+
// 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).
299+
{
300+
name: 'approval_send_back',
301+
label: 'Send back',
302+
icon: 'corner-up-left',
303+
type: 'api',
304+
method: 'POST',
305+
target: '/api/v1/approvals/requests/{id}/revise',
306+
params: [
307+
{ name: 'comment', label: 'Reason', type: 'textarea', required: false },
308+
],
309+
visible: 'record.status == "pending"',
310+
locations: ['record_section'],
311+
successMessage: 'Sent back for revision.',
312+
refreshAfter: true,
313+
},
314+
{
315+
name: 'approval_request_info',
316+
label: 'Request info',
317+
icon: 'help-circle',
318+
type: 'api',
319+
method: 'POST',
320+
target: '/api/v1/approvals/requests/{id}/request-info',
321+
params: [
322+
{ name: 'comment', label: 'What do you need?', type: 'textarea', required: true },
323+
],
324+
visible: 'record.status == "pending"',
325+
locations: ['record_section'],
326+
successMessage: 'Information requested.',
327+
refreshAfter: true,
328+
},
329+
330+
// ── Submitter continuity actions ────────────────────────────────
331+
// 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.
336+
{
337+
name: 'approval_remind',
338+
label: 'Send reminder',
339+
icon: 'bell-ring',
340+
type: 'api',
341+
method: 'POST',
342+
target: '/api/v1/approvals/requests/{id}/remind',
343+
params: [
344+
{ name: 'comment', label: 'Note', type: 'textarea', required: false },
345+
],
346+
visible: 'record.status == "pending" && record.submitter_id == ctx.user.id',
347+
locations: ['record_section'],
348+
successMessage: 'Reminder sent.',
349+
refreshAfter: true,
350+
},
351+
{
352+
name: 'approval_recall',
353+
label: 'Recall',
354+
icon: 'undo-2',
355+
type: 'api',
356+
method: 'POST',
357+
target: '/api/v1/approvals/requests/{id}/recall',
358+
params: [
359+
{ name: 'comment', label: 'Comment', type: 'textarea', required: false },
360+
],
361+
// Recall applies while the request is live for the submitter — pending
362+
// (withdraw) or returned (abandon the revision instead of resubmitting).
363+
visible: '(record.status == "pending" || record.status == "returned") && record.submitter_id == ctx.user.id',
364+
confirmText: 'Recall this request? Approvers can no longer act on it and the record is unlocked.',
365+
locations: ['record_section'],
366+
successMessage: 'Recalled.',
367+
refreshAfter: true,
368+
},
369+
{
370+
name: 'approval_resubmit',
371+
label: 'Resubmit',
372+
icon: 'refresh-cw',
373+
type: 'api',
374+
method: 'POST',
375+
target: '/api/v1/approvals/requests/{id}/resubmit',
376+
params: [
377+
{ name: 'comment', label: 'What changed?', type: 'textarea', required: false },
378+
],
379+
visible: 'record.status == "returned" && record.submitter_id == ctx.user.id',
380+
locations: ['record_section'],
381+
successMessage: 'Resubmitted.',
382+
refreshAfter: true,
383+
},
289384
],
290385
});

packages/rest/src/rest-server.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5948,6 +5948,57 @@ export class RestServer {
59485948
metadata: { summary: 'Recall (withdraw) an approval request', tags: ['approvals'] },
59495949
});
59505950

5951+
// Send back for revision / resubmit (ADR-0044). Both move the flow (the
5952+
// request finalizes `returned` and the run parks at a wait point; a
5953+
// resubmit re-enters the approval node), so — like recall — they are
5954+
// dedicated routes rather than thread interactions. The service enforces
5955+
// access (send-back = a pending approver; resubmit = the submitter) and
5956+
// returns the flow outcome (`autoRejected` / `resumed`) verbatim.
5957+
const flowMoveRoute = (
5958+
action: 'revise' | 'resubmit',
5959+
invoke: (svc: any, id: string, body: any, context: any) => Promise<unknown>,
5960+
) => {
5961+
this.routeManager.register({
5962+
method: 'POST',
5963+
path: `${dataPath}/approvals/requests/:id/${action}`,
5964+
handler: async (req: any, res: any) => {
5965+
try {
5966+
const environmentId = isScoped ? req.params?.environmentId : undefined;
5967+
const context = await this.resolveExecCtx(environmentId, req);
5968+
if (this.enforceAuth(req, res, context)) return;
5969+
const svc = await resolveService(environmentId);
5970+
if (!svc) return respond501(res);
5971+
const body = req.body ?? {};
5972+
try {
5973+
const out = await invoke(svc, req.params.id, body, context ?? {});
5974+
res.json(out);
5975+
} catch (err: any) {
5976+
if (handleApprovalError(res, err)) return;
5977+
throw err;
5978+
}
5979+
} catch (error: any) {
5980+
logError(`[REST] ${action} approval error:`, error);
5981+
res.status(500).json({ code: `APPROVAL_${action.toUpperCase()}_FAILED`, error: String(error?.message ?? error).slice(0, 500) });
5982+
}
5983+
},
5984+
metadata: { summary: `${action} an approval request`, tags: ['approvals'] },
5985+
});
5986+
};
5987+
flowMoveRoute('revise', (svc, id, body, context) => {
5988+
if (typeof svc.sendBack !== 'function') throw new Error('VALIDATION_FAILED: revise is not supported');
5989+
return svc.sendBack(id, {
5990+
actorId: body.actorId ?? body.actor_id ?? context?.userId,
5991+
comment: body.comment,
5992+
}, context);
5993+
});
5994+
flowMoveRoute('resubmit', (svc, id, body, context) => {
5995+
if (typeof svc.resubmit !== 'function') throw new Error('VALIDATION_FAILED: resubmit is not supported');
5996+
return svc.resubmit(id, {
5997+
actorId: body.actorId ?? body.actor_id ?? context?.userId,
5998+
comment: body.comment,
5999+
}, context);
6000+
});
6001+
59516002
// Thread interactions — reassign / remind / request-info / comment.
59526003
// None of these move the flow; they update approver slots or the
59536004
// audit thread. Registered generically: the service method enforces

0 commit comments

Comments
 (0)