From d53e40328e1848bd4c3f6a6938800b22b1682cfc Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Thu, 11 Jun 2026 22:05:07 +0500 Subject: [PATCH] =?UTF-8?q?feat(approvals):=20thread=20interactions=20?= =?UTF-8?q?=E2=80=94=20reassign,=20remind,=20request-info,=20comment=20+?= =?UTF-8?q?=20SLA=20&=20step=20progress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/approval-service.test.ts | 107 ++++++ .../plugin-approvals/src/approval-service.ts | 307 ++++++++++++++++++ .../plugin-approvals/src/approvals-plugin.ts | 10 + .../src/sys-approval-action.object.ts | 4 +- packages/rest/src/rest-server.ts | 64 ++++ .../spec/src/contracts/approval-service.ts | 74 ++++- 6 files changed, 564 insertions(+), 2 deletions(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service.test.ts b/packages/plugins/plugin-approvals/src/approval-service.test.ts index b1191c471c..ccf2519df4 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.test.ts @@ -407,6 +407,113 @@ describe('ApprovalService (node era)', () => { expect(actions.map(a => (a as any).actor_name)).toEqual(['Ada Lovelace', 'Grace Hopper']); }); + // ── thread interactions ───────────────────────────────────────── + + it('reassign: hands the slot to a new approver and audits the move', async () => { + const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX); + const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX); + expect(out.request.pending_approvers).toEqual(['u7', 'u2']); + const actions = await svc.listActions(req.id, SYS); + expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' }); + }); + + it('reassign: notifies the new approver via messaging', async () => { + const emitted: any[] = []; + svc.attachMessaging({ async emit(input) { emitted.push(input); } }); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX); + expect(emitted).toHaveLength(1); + expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] }); + }); + + it('reassign: blocks a non-holder and duplicate targets', async () => { + const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX); + await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, CTX)).rejects.toThrow(/FORBIDDEN/); + await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, CTX)).rejects.toThrow(/VALIDATION_FAILED/); + }); + + it('remind: notifies pending approvers, audits, and throttles repeats', async () => { + const emitted: any[] = []; + svc.attachMessaging({ async emit(input) { emitted.push(input); } }); + const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX); + const out = await svc.remind(req.id, { actorId: 'u1' }, CTX); // u1 = submitter (CTX.userId) + expect(out.notified).toBe(2); + expect(emitted[0]).toMatchObject({ topic: 'approval.reminder', audience: ['u9', 'u2'] }); + const actions = await svc.listActions(req.id, SYS); + expect(actions.at(-1)?.action).toBe('remind'); + // The fake clock steps 1s per call — well inside the 4h cool-down. + await expect(svc.remind(req.id, { actorId: 'u1' }, CTX)).rejects.toThrow(/THROTTLED/); + }); + + it('remind: only the submitter may nudge', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await expect(svc.remind(req.id, { actorId: 'u9' }, { roles: [], permissions: [] } as any)) + .rejects.toThrow(/FORBIDDEN/); + }); + + it('requestInfo: keeps the request pending and notifies the submitter', async () => { + const emitted: any[] = []; + svc.attachMessaging({ async emit(input) { emitted.push(input); } }); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, CTX); + expect(out.request.status).toBe('pending'); + expect(out.request.pending_approvers).toEqual(['u9']); + expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] }); + const actions = await svc.listActions(req.id, SYS); + expect(actions.at(-1)).toMatchObject({ action: 'request_info', comment: 'Need the Q3 numbers' }); + }); + + it('comment: submitter and approver may reply; outsiders may not', async () => { + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX); + await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX); + await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { roles: [], permissions: [] } as any)) + .rejects.toThrow(/FORBIDDEN/); + const actions = await svc.listActions(req.id, SYS); + expect(actions.filter(a => a.action === 'comment')).toHaveLength(2); + }); + + // ── SLA + flow steps ──────────────────────────────────────────── + + it('rows expose sla_due_at when the node declares escalation.timeoutHours', async () => { + const req = await svc.openNodeRequest( + openInput(['u9'], {}, { escalation: { timeoutHours: 48, action: 'notify', notifySubmitter: true } }), CTX, + ); + expect(req.sla_due_at).toBe(new Date(Date.parse(req.created_at!) + 48 * 3600_000).toISOString()); + const noSla = await svc.openNodeRequest(openInput(['u9'], { recordId: 'opp2', record: { id: 'opp2' } }), CTX); + expect(noSla.sla_due_at).toBeUndefined(); + }); + + it('getRequest attaches flow_steps from the owning flow graph', async () => { + svc.attachAutomation({ + async getFlow(name: string) { + if (name !== 'deal_approval') return null; + return { + name: 'deal_approval', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { id: 'approve_step', type: 'approval', label: 'Manager Approval' }, + { id: 'gate', type: 'decision', label: 'Big?' }, + { id: 'exec_step', type: 'approval', label: 'Executive Approval' }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'approve_step' }, + { id: 'e2', source: 'approve_step', target: 'gate', label: 'approve' }, + { id: 'e3', source: 'gate', target: 'exec_step', label: 'true' }, + { id: 'e4', source: 'exec_step', target: 'end', label: 'approve' }, + ], + }; + }, + }); + const req = await svc.openNodeRequest(openInput(['u9']), CTX); + const fresh = await svc.getRequest(req.id, SYS); + expect(fresh?.flow_steps).toEqual([ + { id: 'approve_step', label: 'Manager Approval', state: 'current' }, + { id: 'exec_step', label: 'Executive Approval', state: 'upcoming' }, + ]); + }); + it('enrichment resolves an email submitter via sys_user.email', async () => { engine._tables['sys_user'] = [{ id: 'u7', name: 'Grace Hopper', email: 'grace@example.com' }]; await svc.openNodeRequest(openInput(['u9'], { submitterId: 'grace@example.com' }), CTX); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index 19189028b3..e3ee47aebc 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -47,8 +47,30 @@ export interface ApprovalClock { now(): Date } */ export interface ApprovalResumeSurface { resume?(runId: string, signal?: { output?: Record; branchLabel?: string }): Promise; + /** Flow definition lookup, used to derive step-progress display data. */ + getFlow?(name: string): Promise; } +/** + * Optional messaging surface (ADR-0012 `messaging` service). When attached, + * thread interactions (reassign / remind / request-info / comment) notify the + * affected users; without it they degrade to audit-only. + */ +export interface ApprovalMessagingSurface { + emit(input: { + topic: string; + audience: string[]; + payload?: Record; + severity?: string; + dedupKey?: string; + source?: { object: string; id: string }; + actorId?: string; + }): Promise; +} + +/** Minimum time between submitter reminders on one request. */ +export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000; + const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const; function uid(prefix: string): string { @@ -114,9 +136,19 @@ function rowFromRequest(row: any): ApprovalRequestRow { submitted_at: row.created_at ?? undefined, process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name), step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step), + sla_due_at: slaDueAt(row.created_at, cfg), } as any; } +/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */ +function slaDueAt(createdAt: unknown, cfg: any): string | undefined { + const hours = cfg?.escalation?.timeoutHours; + if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined; + const t = Date.parse(String(createdAt)); + if (Number.isNaN(t)) return undefined; + return new Date(t + hours * 3600_000).toISOString(); +} + function rowFromAction(row: any): ApprovalActionRow { return { id: String(row.id), @@ -141,6 +173,8 @@ export interface ApprovalServiceOptions { * available. */ automation?: ApprovalResumeSurface; + /** Optional messaging service for thread notifications. */ + messaging?: ApprovalMessagingSurface; } export class ApprovalService implements IApprovalService { @@ -148,12 +182,14 @@ export class ApprovalService implements IApprovalService { private readonly clock: ApprovalClock; private readonly logger?: ApprovalServiceOptions['logger']; private automation?: ApprovalResumeSurface; + private messaging?: ApprovalMessagingSurface; constructor(opts: ApprovalServiceOptions) { this.engine = opts.engine; this.clock = opts.clock ?? { now: () => new Date() }; this.logger = opts.logger; this.automation = opts.automation; + this.messaging = opts.messaging; } /** Attach (or replace) the automation surface used to resume flow runs. */ @@ -161,6 +197,45 @@ export class ApprovalService implements IApprovalService { this.automation = automation; } + /** Attach (or replace) the messaging surface used for thread notifications. */ + attachMessaging(messaging: ApprovalMessagingSurface): void { + this.messaging = messaging; + } + + /** Best-effort notification fan-out — failures only log. */ + private async notify(input: { + topic: string; + audience: string[]; + payload?: Record; + dedupKey?: string; + source?: { object: string; id: string }; + actorId?: string; + }): Promise { + const audience = input.audience.filter(a => a && !a.includes(':')); + if (!this.messaging || !audience.length) return 0; + try { + await this.messaging.emit({ severity: 'info', ...input, audience }); + return audience.length; + } catch (err: any) { + this.logger?.warn?.('[approvals] notification failed', { + topic: input.topic, error: err?.message ?? String(err), + }); + return 0; + } + } + + /** Load a request row and assert it is still pending. */ + private async loadPendingRow(requestId: string): Promise { + if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required'); + const rows = await this.engine.find('sys_approval_request', { + where: { id: requestId }, limit: 1, context: SYSTEM_CTX, + }); + const raw: any = Array.isArray(rows) ? rows[0] : null; + if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`); + if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`); + return raw; + } + /** * Expand the approvers on an Approval node into user IDs by querying the * graph tables for `team:` / `department:` / `role:` / `manager:` approver @@ -561,6 +636,197 @@ export class ApprovalService implements IApprovalService { return { request: fresh!, runId, resumed }; } + // ── Thread interactions (no flow movement) ─────────────────── + + /** + * Hand a pending-approver slot to someone else. `from` defaults to the + * actor itself; the actor must hold the slot being handed over (or be a + * system caller). Audits `reassign` and notifies the new approver. + */ + async reassign( + requestId: string, + input: { actorId: string; to: string; from?: string; comment?: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow }> { + if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); + const to = String(input?.to ?? '').trim(); + if (!to) throw new Error('VALIDATION_FAILED: `to` (new approver) is required'); + const raw = await this.loadPendingRow(requestId); + + const pending = csvSplit(raw.pending_approvers); + const from = String(input.from ?? input.actorId).trim(); + if (!pending.includes(from)) { + throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`); + } + if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) { + throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`); + } + if (pending.includes(to)) { + throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`); + } + + const next = pending.map(a => (a === from ? to : a)); + const now = this.clock.now().toISOString(); + // Audit first, then mutate — mirrors decideNode(), so a failed audit + // write can never leave a moved slot without a trail. + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null, + step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign', + actor_id: input.actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now, + }, { context: SYSTEM_CTX }); + await this.engine.update('sys_approval_request', { + id: requestId, pending_approvers: next.join(','), updated_at: now, + }, { context: SYSTEM_CTX }); + + await this.notify({ + topic: 'approval.reassigned', + audience: [to], + actorId: input.actorId, + source: { object: 'sys_approval_request', id: requestId }, + dedupKey: `approval-reassign-${requestId}-${to}`, + payload: { + title: 'Approval handed to you', + message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`, + actionUrl: '/system/approvals', + }, + }); + + const fresh = await this.getRequest(requestId, context); + return { request: fresh! }; + } + + /** + * Submitter nudge — notify every pending approver. Throttled to one + * reminder per {@link REMIND_COOLDOWN_MS} per request. + */ + async remind( + requestId: string, + input: { actorId: string; comment?: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow; notified: number }> { + if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); + const raw = await this.loadPendingRow(requestId); + if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) { + throw new Error('FORBIDDEN: only the submitter may send reminders'); + } + + const acts = await this.engine.find('sys_approval_action', { + where: { request_id: requestId, action: 'remind' }, + orderBy: [{ field: 'created_at', direction: 'desc' }], limit: 1, context: SYSTEM_CTX, + }); + const last: any = Array.isArray(acts) ? acts[0] : null; + const now = this.clock.now(); + if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) { + throw new Error('THROTTLED: a reminder was already sent recently'); + } + + const pending = csvSplit(raw.pending_approvers); + const nowIso = now.toISOString(); + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null, + step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'remind', + actor_id: input.actorId, comment: input.comment ?? null, created_at: nowIso, + }, { context: SYSTEM_CTX }); + + const notified = await this.notify({ + topic: 'approval.reminder', + audience: pending, + actorId: input.actorId, + source: { object: 'sys_approval_request', id: requestId }, + dedupKey: `approval-remind-${requestId}-${nowIso}`, + payload: { + title: 'Approval reminder', + message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`, + actionUrl: '/system/approvals', + }, + }); + + const fresh = await this.getRequest(requestId, context); + return { request: fresh!, notified }; + } + + /** + * Approver asks the submitter for more information. The request stays + * pending — a thread interaction, not a flow decision. + */ + async requestInfo( + requestId: string, + input: { actorId: string; comment: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow }> { + if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); + if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required'); + const raw = await this.loadPendingRow(requestId); + const pending = csvSplit(raw.pending_approvers); + if (!context.isSystem && !pending.includes(input.actorId)) { + throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`); + } + + const now = this.clock.now().toISOString(); + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null, + step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'request_info', + actor_id: input.actorId, comment: input.comment.trim(), created_at: now, + }, { context: SYSTEM_CTX }); + + if (raw.submitter_id) { + await this.notify({ + topic: 'approval.request_info', + audience: [String(raw.submitter_id)], + actorId: input.actorId, + source: { object: 'sys_approval_request', id: requestId }, + payload: { + title: 'More information requested', + message: input.comment.trim(), + actionUrl: '/system/approvals', + }, + }); + } + + const fresh = await this.getRequest(requestId, context); + return { request: fresh! }; + } + + /** Free-form reply on the thread (submitter or any pending approver). */ + async comment( + requestId: string, + input: { actorId: string; comment: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow }> { + if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required'); + if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required'); + const raw = await this.loadPendingRow(requestId); + const pending = csvSplit(raw.pending_approvers); + const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId); + if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) { + throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`); + } + + const now = this.clock.now().toISOString(); + await this.engine.insert('sys_approval_action', { + id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null, + step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment', + actor_id: input.actorId, comment: input.comment.trim(), created_at: now, + }, { context: SYSTEM_CTX }); + + // Notify the other side of the thread. + const audience = isSubmitter ? pending : [String(raw.submitter_id ?? '')].filter(Boolean); + await this.notify({ + topic: 'approval.comment', + audience, + actorId: input.actorId, + source: { object: 'sys_approval_request', id: requestId }, + payload: { + title: 'New comment on an approval', + message: input.comment.trim(), + actionUrl: '/system/approvals', + }, + }); + + const fresh = await this.getRequest(requestId, context); + return { request: fresh! }; + } + // ── Display enrichment ─────────────────────────────────────── /** @@ -820,9 +1086,50 @@ export class ApprovalService implements IApprovalService { if (!Array.isArray(rows) || !rows[0]) return null; const row = rowFromRequest(rows[0]); await this.enrichRows([row]); + await this.attachFlowSteps(row); return row; } + /** + * Derive approval-step progress from the owning flow's graph (single-read + * enrichment only — list reads skip it). Walks from the start node + * preferring `approve`/`true` edges, so the result is the flow's main + * approval trunk; conditional side-steps show as part of the potential + * path. Display-only and best-effort. + */ + private async attachFlowSteps(row: ApprovalRequestRow): Promise { + try { + const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined; + if (!flowName || typeof this.automation?.getFlow !== 'function') return; + const flow: any = await this.automation.getFlow(flowName); + if (!flow?.nodes?.length) return; + const nodesById = new Map(flow.nodes.map((n: any) => [n.id, n])); + const steps: Array<{ id: string; label: string }> = []; + const seen = new Set(); + let cur: any = flow.nodes.find((n: any) => n.type === 'start'); + while (cur && !seen.has(cur.id)) { + seen.add(cur.id); + if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id }); + const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id); + if (!out.length) break; + const pick = out.find((e: any) => e.label === 'approve') + ?? out.find((e: any) => e.label === 'true') + ?? out[0]; + cur = nodesById.get(pick.target); + } + if (steps.length === 0) return; + const currentId = row.flow_node_id ?? row.current_step; + const currentIdx = steps.findIndex(s => s.id === currentId); + (row as any).flow_steps = steps.map((s, i) => ({ + ...s, + state: currentIdx < 0 ? 'upcoming' + : i < currentIdx ? 'done' + : i === currentIdx ? (row.status === 'approved' ? 'done' : 'current') + : 'upcoming', + })); + } catch { /* display-only — never fail the read */ } + } + async listActions(requestId: string, context: SharingExecutionContext): Promise { if (!requestId) return []; // Tenant gate: ensure the caller can see the parent request before diff --git a/packages/plugins/plugin-approvals/src/approvals-plugin.ts b/packages/plugins/plugin-approvals/src/approvals-plugin.ts index 9509ee4aa7..ca73e1ee38 100644 --- a/packages/plugins/plugin-approvals/src/approvals-plugin.ts +++ b/packages/plugins/plugin-approvals/src/approvals-plugin.ts @@ -113,6 +113,16 @@ export class ApprovalsServicePlugin implements Plugin { ctx.registerService('approvals', this.service); ctx.logger.info('ApprovalsServicePlugin: service registered'); + // Optional messaging service (ADR-0012): thread interactions (reassign / + // remind / request-info / comment) notify users when present; without it + // they degrade to audit-only. + try { + const messaging = ctx.getService('messaging'); + if (messaging && typeof messaging.emit === 'function') { + this.service.attachMessaging(messaging); + } + } catch { /* messaging not installed */ } + // ADR-0019: contribute the `approval` node to the flow engine when one is // present. The node lets a flow suspend on an approval and resume on // decision; the service is wired to the same engine so `decide()` can diff --git a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts index b3afc4213b..a500d6d5ae 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-action.object.ts @@ -88,7 +88,9 @@ export const SysApprovalAction = ObjectSchema.create({ }), action: Field.select( - ['submit', 'approve', 'reject', 'recall', 'escalate'], + // Keep in sync with `ApprovalActionKind` (spec/contracts). The last four + // are thread interactions — they never move the flow. + ['submit', 'approve', 'reject', 'recall', 'escalate', 'reassign', 'remind', 'request_info', 'comment'], { label: 'Action', required: true, diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index f4444a1f27..f3bc7dabf4 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -4252,6 +4252,7 @@ export class RestServer { [/^VALIDATION_FAILED/, 400, 'VALIDATION_FAILED'], [/^DUPLICATE_REQUEST/, 409, 'DUPLICATE_REQUEST'], [/^INVALID_STATE/, 409, 'INVALID_STATE'], + [/^THROTTLED/, 429, 'THROTTLED'], [/^FORBIDDEN/, 403, 'FORBIDDEN'], [/^REQUEST_NOT_FOUND/, 404, 'REQUEST_NOT_FOUND'], ]; @@ -4401,6 +4402,69 @@ export class RestServer { metadata: { summary: 'Recall (withdraw) an approval request', tags: ['approvals'] }, }); + // Thread interactions — reassign / remind / request-info / comment. + // None of these move the flow; they update approver slots or the + // audit thread. Registered generically: the service method enforces + // the per-action permission (slot holder / submitter / participant). + const threadRoute = ( + action: 'reassign' | 'remind' | 'request-info' | 'comment', + invoke: (svc: any, id: string, body: any, context: any) => Promise, + ) => { + this.routeManager.register({ + method: 'POST', + path: `${dataPath}/approvals/requests/:id/${action}`, + handler: async (req: any, res: any) => { + try { + const environmentId = isScoped ? req.params?.environmentId : undefined; + const context = await this.resolveExecCtx(environmentId, req); + if (this.enforceAuth(req, res, context)) return; + const svc = await resolveService(environmentId); + if (!svc) return respond501(res); + const body = req.body ?? {}; + try { + const out = await invoke(svc, req.params.id, body, context ?? {}); + res.json(out); + } catch (err: any) { + if (handleApprovalError(res, err)) return; + throw err; + } + } catch (error: any) { + logError(`[REST] ${action} approval error:`, error); + res.status(500).json({ code: `APPROVAL_${action.toUpperCase().replace('-', '_')}_FAILED`, error: String(error?.message ?? error).slice(0, 500) }); + } + }, + metadata: { summary: `${action} on an approval request`, tags: ['approvals'] }, + }); + }; + threadRoute('reassign', (svc, id, body, context) => { + if (typeof svc.reassign !== 'function') throw new Error('VALIDATION_FAILED: reassign is not supported'); + return svc.reassign(id, { + actorId: body.actorId ?? body.actor_id ?? context?.userId, + to: body.to, from: body.from, comment: body.comment, + }, context); + }); + threadRoute('remind', (svc, id, body, context) => { + if (typeof svc.remind !== 'function') throw new Error('VALIDATION_FAILED: remind is not supported'); + return svc.remind(id, { + actorId: body.actorId ?? body.actor_id ?? context?.userId, + comment: body.comment, + }, context); + }); + threadRoute('request-info', (svc, id, body, context) => { + if (typeof svc.requestInfo !== 'function') throw new Error('VALIDATION_FAILED: request-info is not supported'); + return svc.requestInfo(id, { + actorId: body.actorId ?? body.actor_id ?? context?.userId, + comment: body.comment, + }, context); + }); + threadRoute('comment', (svc, id, body, context) => { + if (typeof svc.comment !== 'function') throw new Error('VALIDATION_FAILED: comment is not supported'); + return svc.comment(id, { + actorId: body.actorId ?? body.actor_id ?? context?.userId, + comment: body.comment, + }, context); + }); + this.routeManager.register({ method: 'GET', path: `${dataPath}/approvals/requests/:id/actions`, diff --git a/packages/spec/src/contracts/approval-service.ts b/packages/spec/src/contracts/approval-service.ts index e8d6d3a63b..fbdaf988fa 100644 --- a/packages/spec/src/contracts/approval-service.ts +++ b/packages/spec/src/contracts/approval-service.ts @@ -70,15 +70,43 @@ export interface ApprovalRequestRow { * record's display name), so inbox summaries never show foreign-key ids. */ payload_display?: Record; + /** + * SLA deadline, when the node config carries `escalation.timeoutHours`: + * `created_at + timeoutHours`. Display-only for now — automatic escalation + * needs a scheduler pass and is not yet wired. + */ + sla_due_at?: string; + /** + * The owning flow's approval steps in graph order, for progress display + * (resolved on single-request reads when the automation engine is + * attached). `state` is relative to this request's node. + */ + flow_steps?: Array<{ id: string; label: string; state: 'done' | 'current' | 'upcoming' }>; } +/** Kinds of entries on a request's audit trail. */ +export type ApprovalActionKind = + | 'submit' + | 'approve' + | 'reject' + | 'recall' + | 'escalate' + /** A pending approver handed their slot to someone else. */ + | 'reassign' + /** The submitter nudged the pending approvers. */ + | 'remind' + /** An approver asked the submitter for more information (request stays pending). */ + | 'request_info' + /** A free-form reply on the thread (submitter or approver). */ + | 'comment'; + /** Audit row. */ export interface ApprovalActionRow { id: string; request_id: string; step_name?: string; step_index?: number; - action: 'submit' | 'approve' | 'reject' | 'recall' | 'escalate'; + action: ApprovalActionKind; actor_id?: string; comment?: string; created_at?: string; @@ -168,6 +196,50 @@ export interface IApprovalService { */ recall(requestId: string, input: ApprovalRecallInput, context: SharingExecutionContext): Promise; + /** + * Hand a pending-approver slot to someone else. The actor must currently + * be a pending approver (or system); `from` defaults to the actor's own + * matching identity. Audits a `reassign` action and notifies the new + * approver when a messaging service is attached. + */ + reassign( + requestId: string, + input: { actorId: string; to: string; from?: string; comment?: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow }>; + + /** + * Submitter nudge: notify every pending approver. Throttled — repeat + * reminders inside the cool-down window are rejected (`THROTTLED`). + * Audits a `remind` action. + */ + remind( + requestId: string, + input: { actorId: string; comment?: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow; notified: number }>; + + /** + * Approver asks the submitter for more information. The request STAYS + * pending (no flow movement) — this is a thread interaction, audited as + * `request_info`, with the submitter notified when messaging is attached. + */ + requestInfo( + requestId: string, + input: { actorId: string; comment: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow }>; + + /** + * Free-form reply on the request thread (submitter or any pending + * approver). Audited as `comment`. + */ + comment( + requestId: string, + input: { actorId: string; comment: string }, + context: SharingExecutionContext, + ): Promise<{ request: ApprovalRequestRow }>; + /** Audit trail for a request. */ listActions(requestId: string, context: SharingExecutionContext): Promise; }