feat(FN-3549): add mailbox approvals inbox and decision flow

- Add dashboard approvals APIs and route handlers for listing requests, fetching detail, and submitting approve/deny decisions
- Emit approval SSE events and subscribe mailbox refresh logic to keep approval lists and badges live
- Extend Mailbox view with an Approvals tab, pending/history filters, request detail history, and approve/deny actions
- Update mailbox styles, approval route tests, and docs for the new approvals workflow

Fusion-Task-Id: FN-3549
This commit is contained in:
Fusion
2026-05-09 05:38:34 -07:00
committed by gsxdsm
parent 9e6574c777
commit f3626489ed
9 changed files with 770 additions and 113 deletions

View File

@@ -1,6 +1,7 @@
import { AgentStore, ApprovalRequestStore, type ApprovalRequestActorSnapshot, type ApprovalRequestStatus } from "@fusion/core";
import { ApiError, badRequest, conflict, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
import { emitApprovalSseEvent } from "../sse.js";
const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
actorId: "user",
@@ -8,10 +9,39 @@ const DEFAULT_ACTOR: ApprovalRequestActorSnapshot = {
actorName: "User",
};
function parseOptionalString(value: unknown, field: string): string | undefined {
if (value === undefined || value === null || value === "") return undefined;
if (typeof value !== "string") throw badRequest(`${field} must be a string`);
return value;
interface ApprovalRequestSummaryDto {
id: string;
status: ApprovalRequestStatus;
actionCategory: string;
actionSummary: string;
agentId: string;
taskId?: string;
createdAt: string;
updatedAt: string;
decidedAt?: string;
decidedBy?: string;
}
interface ApprovalRequestDetailDto extends ApprovalRequestSummaryDto {
requester: ApprovalRequestActorSnapshot;
runId?: string;
requestedAt: string;
completedAt?: string;
targetAction: {
category: string;
action: string;
summary: string;
resourceType: string;
resourceId: string;
context?: Record<string, unknown>;
};
history: Array<{
id: string;
eventType: string;
actor: ApprovalRequestActorSnapshot;
note?: string;
createdAt: string;
}>;
}
function parseOptionalInt(value: unknown, field: string): number | undefined {
@@ -27,6 +57,46 @@ function parseStatus(value: unknown): ApprovalRequestStatus | undefined {
throw badRequest("status must be one of: pending, approved, denied, completed");
}
function getDeciderActorId(
history: Array<{ eventType: string; actor: ApprovalRequestActorSnapshot }>,
): string | undefined {
const decisionEvent = [...history].reverse().find((entry) => entry.eventType === "approved" || entry.eventType === "denied");
return decisionEvent?.actor.actorId;
}
function toSummaryDto(
request: import("@fusion/core").ApprovalRequest,
history: Array<{ eventType: string; actor: ApprovalRequestActorSnapshot }>,
): ApprovalRequestSummaryDto {
return {
id: request.id,
status: request.status,
actionCategory: request.targetAction.category,
actionSummary: request.targetAction.summary,
agentId: request.requester.actorId,
taskId: request.taskId,
createdAt: request.createdAt,
updatedAt: request.updatedAt,
decidedAt: request.decidedAt,
decidedBy: getDeciderActorId(history),
};
}
function toDetailDto(
request: import("@fusion/core").ApprovalRequest,
history: import("@fusion/core").ApprovalRequestAuditEvent[],
): ApprovalRequestDetailDto {
return {
...toSummaryDto(request, history),
requester: request.requester,
runId: request.runId,
requestedAt: request.requestedAt,
completedAt: request.completedAt,
targetAction: request.targetAction,
history,
};
}
async function resumeAfterDecision(params: {
scopedStore: import("@fusion/core").TaskStore;
request: import("@fusion/core").ApprovalRequest;
@@ -69,57 +139,55 @@ async function resumeAfterDecision(params: {
export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError, runtimeLogger } = ctx;
router.get("/approval-requests", async (req, res) => {
router.get("/approvals", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requests = approvalStore.list({
status: parseStatus(req.query.status),
requesterActorId: parseOptionalString(req.query.requesterActorId, "requesterActorId"),
taskId: parseOptionalString(req.query.taskId, "taskId"),
runId: parseOptionalString(req.query.runId, "runId"),
limit: parseOptionalInt(req.query.limit, "limit"),
offset: parseOptionalInt(req.query.offset, "offset"),
const status = parseStatus(req.query.status);
const limit = parseOptionalInt(req.query.limit, "limit") ?? 50;
const offset = parseOptionalInt(req.query.offset, "offset") ?? 0;
const requests = approvalStore.list({ status, limit, offset });
const summaries = requests.map((request) => {
const history = approvalStore.getAuditHistory(request.id);
return toSummaryDto(request, history);
});
res.json(requests);
const total = approvalStore.list({ status, limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
const pendingCount = approvalStore.list({ status: "pending", limit: Number.MAX_SAFE_INTEGER, offset: 0 }).length;
res.json({ requests: summaries, total, pendingCount });
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/approval-requests/:id", async (req, res) => {
router.get("/approvals/:id", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const request = approvalStore.get(requestId);
if (!request) throw notFound("Approval request not found");
res.json(request);
const history = approvalStore.getAuditHistory(requestId);
res.json(toDetailDto(request, history));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
router.get("/approval-requests/:id/audit", async (req, res) => {
router.post("/approvals/:id/decision", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const request = approvalStore.get(requestId);
if (!request) throw notFound("Approval request not found");
res.json(approvalStore.getAuditHistory(requestId));
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
});
const body = (req.body ?? {}) as { decision?: "approve" | "deny"; comment?: string; actor?: ApprovalRequestActorSnapshot };
if (body.decision !== "approve" && body.decision !== "deny") {
throw badRequest("decision must be one of: approve, deny");
}
if (body.comment !== undefined && typeof body.comment !== "string") {
throw badRequest("comment must be a string");
}
const decideHandler = (status: "approved" | "denied") => async (req: import("express").Request, res: import("express").Response) => {
try {
const body = (req.body ?? {}) as { actor?: ApprovalRequestActorSnapshot; note?: string };
const { store: scopedStore } = await getProjectContext(req);
const { store: scopedStore, projectId } = await getProjectContext(req);
const approvalStore = new ApprovalRequestStore(scopedStore.getDatabase());
const requestId = String(req.params.id);
const existing = approvalStore.get(requestId);
@@ -129,13 +197,11 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
if (!actor || typeof actor.actorId !== "string" || typeof actor.actorType !== "string" || typeof actor.actorName !== "string") {
throw badRequest("actor must include actorId, actorType, and actorName");
}
if (body.note !== undefined && typeof body.note !== "string") {
throw badRequest("note must be a string");
}
const targetStatus = body.decision === "approve" ? "approved" : "denied";
let updated;
try {
updated = approvalStore.decide(requestId, status, { actor, note: body.note });
updated = approvalStore.decide(requestId, targetStatus, { actor, note: body.comment });
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("Invalid approval request transition")) {
@@ -145,13 +211,14 @@ export function registerApprovalRoutes(ctx: ApiRoutesContext): void {
}
await resumeAfterDecision({ scopedStore, request: updated, runtimeLogger });
res.json(updated);
const history = approvalStore.getAuditHistory(requestId);
const detail = toDetailDto(updated, history);
emitApprovalSseEvent("approval:updated", detail, projectId);
emitApprovalSseEvent("approval:decided", detail, projectId);
res.json(detail);
} catch (err: unknown) {
if (err instanceof ApiError) throw err;
rethrowAsApiError(err);
}
};
router.post("/approval-requests/:id/approve", decideHandler("approved"));
router.post("/approval-requests/:id/deny", decideHandler("denied"));
});
}