Skip to content

Commit d53e403

Browse files
os-zhuangclaude
andcommitted
feat(approvals): thread interactions — reassign, remind, request-info, comment + SLA & step progress
Closes the collaboration gap vs mainstream approval centers (ServiceNow / SAP Fiori My Inbox / DingTalk-Feishu). None of these move the flow; they operate on approver slots or the audit thread: - reassign(): a pending approver hands their slot to someone else (audit-first ordering mirrors decideNode), with the new approver notified via the optional `messaging` service. - remind(): submitter nudge to every pending approver, throttled to one per 4h per request (THROTTLED → HTTP 429). - requestInfo(): approver sends the request back to the submitter for more material — request stays pending, submitter notified. - comment(): free-form thread reply (submitter or pending approver), notifying the other side. - `sys_approval_action.action` enum gains reassign/remind/request_info/ comment (kept in sync with the new ApprovalActionKind contract type). - Rows expose `sla_due_at` (created_at + escalation.timeoutHours from the node config — display-only; automatic escalation still needs a scheduler pass) and single reads attach `flow_steps` (the owning flow's approval trunk with done/current/upcoming states) via the automation surface's getFlow(). - REST: POST reassign / remind / request-info / comment routes with the shared error mapping (+THROTTLED→429); messaging wired in the plugin when installed, degrading to audit-only without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a1251e4 commit d53e403

6 files changed

Lines changed: 564 additions & 2 deletions

File tree

packages/plugins/plugin-approvals/src/approval-service.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,113 @@ describe('ApprovalService (node era)', () => {
407407
expect(actions.map(a => (a as any).actor_name)).toEqual(['Ada Lovelace', 'Grace Hopper']);
408408
});
409409

410+
// ── thread interactions ─────────────────────────────────────────
411+
412+
it('reassign: hands the slot to a new approver and audits the move', async () => {
413+
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
414+
const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
415+
expect(out.request.pending_approvers).toEqual(['u7', 'u2']);
416+
const actions = await svc.listActions(req.id, SYS);
417+
expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' });
418+
});
419+
420+
it('reassign: notifies the new approver via messaging', async () => {
421+
const emitted: any[] = [];
422+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
423+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
424+
await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
425+
expect(emitted).toHaveLength(1);
426+
expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] });
427+
});
428+
429+
it('reassign: blocks a non-holder and duplicate targets', async () => {
430+
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
431+
await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, CTX)).rejects.toThrow(/FORBIDDEN/);
432+
await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
433+
});
434+
435+
it('remind: notifies pending approvers, audits, and throttles repeats', async () => {
436+
const emitted: any[] = [];
437+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
438+
const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
439+
const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId)
440+
expect(out.notified).toBe(2);
441+
expect(emitted[0]).toMatchObject({ topic: 'approval.reminder', audience: ['u9', 'u2'] });
442+
const actions = await svc.listActions(req.id, SYS);
443+
expect(actions.at(-1)?.action).toBe('remind');
444+
// The fake clock steps 1s per call — well inside the 4h cool-down.
445+
await expect(svc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/);
446+
});
447+
448+
it('remind: only the submitter may nudge', async () => {
449+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
450+
await expect(svc.remind(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any))
451+
.rejects.toThrow(/FORBIDDEN/);
452+
});
453+
454+
it('requestInfo: keeps the request pending and notifies the submitter', async () => {
455+
const emitted: any[] = [];
456+
svc.attachMessaging({ async emit(input) { emitted.push(input); } });
457+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
458+
const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, CTX);
459+
expect(out.request.status).toBe('pending');
460+
expect(out.request.pending_approvers).toEqual(['u9']);
461+
expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] });
462+
const actions = await svc.listActions(req.id, SYS);
463+
expect(actions.at(-1)).toMatchObject({ action: 'request_info', comment: 'Need the Q3 numbers' });
464+
});
465+
466+
it('comment: submitter and approver may reply; outsiders may not', async () => {
467+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
468+
await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
469+
await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX);
470+
await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { roles: [], permissions: [] } as any))
471+
.rejects.toThrow(/FORBIDDEN/);
472+
const actions = await svc.listActions(req.id, SYS);
473+
expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
474+
});
475+
476+
// ── SLA + flow steps ────────────────────────────────────────────
477+
478+
it('rows expose sla_due_at when the node declares escalation.timeoutHours', async () => {
479+
const req = await svc.openNodeRequest(
480+
openInput(['u9'], {}, { escalation: { timeoutHours: 48, action: 'notify', notifySubmitter: true } }), CTX,
481+
);
482+
expect(req.sla_due_at).toBe(new Date(Date.parse(req.created_at!) + 48 * 3600_000).toISOString());
483+
const noSla = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX);
484+
expect(noSla.sla_due_at).toBeUndefined();
485+
});
486+
487+
it('getRequest attaches flow_steps from the owning flow graph', async () => {
488+
svc.attachAutomation({
489+
async getFlow(name: string) {
490+
if (name !== 'deal_approval') return null;
491+
return {
492+
name: 'deal_approval',
493+
nodes: [
494+
{ id: 'start', type: 'start', label: 'Start' },
495+
{ id: 'approve_step', type: 'approval', label: 'Manager Approval' },
496+
{ id: 'gate', type: 'decision', label: 'Big?' },
497+
{ id: 'exec_step', type: 'approval', label: 'Executive Approval' },
498+
{ id: 'end', type: 'end', label: 'End' },
499+
],
500+
edges: [
501+
{ id: 'e1', source: 'start', target: 'approve_step' },
502+
{ id: 'e2', source: 'approve_step', target: 'gate', label: 'approve' },
503+
{ id: 'e3', source: 'gate', target: 'exec_step', label: 'true' },
504+
{ id: 'e4', source: 'exec_step', target: 'end', label: 'approve' },
505+
],
506+
};
507+
},
508+
});
509+
const req = await svc.openNodeRequest(openInput(['u9']), CTX);
510+
const fresh = await svc.getRequest(req.id, SYS);
511+
expect(fresh?.flow_steps).toEqual([
512+
{ id: 'approve_step', label: 'Manager Approval', state: 'current' },
513+
{ id: 'exec_step', label: 'Executive Approval', state: 'upcoming' },
514+
]);
515+
});
516+
410517
it('enrichment resolves an email submitter via sys_user.email', async () => {
411518
engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }];
412519
await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX);

0 commit comments

Comments
 (0)