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:
@@ -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"));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user