feat(FN-3787): add approval workflow and share blocks to reports plugin

Added an approval workflow to the reports plugin comprising a state machine (`approval.ts`), share blocks logic (`share-blocks.ts`), API routes for approvals, and two new dashboard panels (ReportApprovalPanel and ShareBlocksPanel), with corresponding tests; also updated the plugin README and added a

Fusion-Task-Id: FN-3787
This commit is contained in:
Fusion
2026-05-10 14:06:49 -07:00
committed by gsxdsm
parent 1209e6b452
commit 63fe25e014
23 changed files with 1063 additions and 17 deletions

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "vitest";
import { applyDecision, nextApprovalState, type ApprovalAction, type ApprovalActor, type ApprovalDecision, type ApprovalSettings, type ApprovalState } from "../approval.js";
import type { Report } from "../store/report-types.js";
interface MatrixCase {
approvalRequired: boolean;
autoPublishOnApproval: boolean;
actorIsApprover: boolean;
action: ApprovalAction;
expected: ApprovalState | "invalid_transition" | "unauthorized";
}
const matrix: MatrixCase[] = [
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: false, action: "approve", expected: "approved" },
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: false, action: "reject", expected: "rejected" },
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: false, action: "publish", expected: "invalid_transition" },
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: true, action: "approve", expected: "approved" },
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: true, action: "reject", expected: "rejected" },
{ approvalRequired: false, autoPublishOnApproval: false, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: false, action: "approve", expected: "published" },
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: false, action: "reject", expected: "rejected" },
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: false, action: "publish", expected: "invalid_transition" },
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: true, action: "approve", expected: "published" },
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: true, action: "reject", expected: "rejected" },
{ approvalRequired: false, autoPublishOnApproval: true, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: false, action: "approve", expected: "unauthorized" },
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: false, action: "reject", expected: "unauthorized" },
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: false, action: "publish", expected: "unauthorized" },
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: true, action: "approve", expected: "approved" },
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: true, action: "reject", expected: "rejected" },
{ approvalRequired: true, autoPublishOnApproval: false, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: false, action: "approve", expected: "unauthorized" },
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: false, action: "reject", expected: "unauthorized" },
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: false, action: "publish", expected: "unauthorized" },
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: true, action: "approve", expected: "published" },
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: true, action: "reject", expected: "rejected" },
{ approvalRequired: true, autoPublishOnApproval: true, actorIsApprover: true, action: "publish", expected: "invalid_transition" },
];
function makeSettings(input: Pick<MatrixCase, "approvalRequired" | "autoPublishOnApproval" | "actorIsApprover">): ApprovalSettings {
return {
approvalRequired: input.approvalRequired,
autoPublishOnApproval: input.autoPublishOnApproval,
approverAgentIds: input.actorIsApprover ? ["approver-1"] : ["approver-2"],
publishTargets: ["dashboard"],
};
}
function makeDecision(action: ApprovalAction): ApprovalDecision {
return { action, decidedAt: "2026-05-10T00:00:00.000Z", decidedBy: "approver-1", note: "ship" };
}
function makeReport(state: ApprovalState): Report {
return {
id: "rep_1",
cadence: "daily",
periodStart: "2026-05-01",
periodEnd: "2026-05-01",
title: "Title",
status: "review_complete",
generationStartedAt: "2026-05-01T00:00:00.000Z",
generationCompletedAt: null,
reviewStartedAt: null,
reviewCompletedAt: "2026-05-01T01:00:00.000Z",
approvedAt: null,
approvedBy: null,
publishedAt: null,
archivedAt: null,
failureReason: null,
approvalState: state,
approvalHistory: [],
draftMarkdown: "# x",
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: null,
metadata: {},
combinedReview: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
};
}
describe("approval state machine", () => {
it.each(matrix)("transition matrix %#", (entry) => {
const settings = makeSettings(entry);
const actor: ApprovalActor = { id: "approver-1", type: "agent" };
const result = nextApprovalState("awaiting_approval", entry.action, settings, actor);
if ("error" in result) {
expect(result.error).toBe(entry.expected);
return;
}
expect(result.next).toBe(entry.expected);
});
it("returns invalid_transition for not_required actions", () => {
const settings: ApprovalSettings = {
approvalRequired: true,
autoPublishOnApproval: false,
approverAgentIds: ["approver-1"],
publishTargets: [],
};
const actor: ApprovalActor = { id: "approver-1", type: "agent" };
expect(nextApprovalState("not_required", "approve", settings, actor)).toEqual({ error: "invalid_transition" });
expect(nextApprovalState("not_required", "reject", settings, actor)).toEqual({ error: "invalid_transition" });
expect(nextApprovalState("not_required", "publish", settings, actor)).toEqual({ error: "invalid_transition" });
});
it("allows any human when approverAgentIds is empty", () => {
const settings: ApprovalSettings = {
approvalRequired: true,
autoPublishOnApproval: false,
approverAgentIds: [],
publishTargets: [],
};
const human: ApprovalActor = { id: "u-1", type: "human" };
expect(nextApprovalState("awaiting_approval", "approve", settings, human)).toEqual({ next: "approved" });
});
it("keeps agents unauthorized when approverAgentIds is empty", () => {
const settings: ApprovalSettings = {
approvalRequired: true,
autoPublishOnApproval: false,
approverAgentIds: [],
publishTargets: [],
};
const agent: ApprovalActor = { id: "approver-1", type: "agent" };
expect(nextApprovalState("awaiting_approval", "approve", settings, agent)).toEqual({ error: "unauthorized" });
});
it("applyDecision auto-publish chain updates report fields", () => {
const report = makeReport("awaiting_approval");
const settings: ApprovalSettings = {
approvalRequired: true,
autoPublishOnApproval: true,
approverAgentIds: ["approver-1"],
publishTargets: ["dashboard", "html-export"],
};
const result = applyDecision(report, makeDecision("approve"), settings, { id: "approver-1", type: "agent" });
expect("error" in result).toBe(false);
if ("error" in result) return;
expect(result.updatedReport.approvalState).toBe("published");
expect(result.updatedReport.status).toBe("published");
expect(result.updatedReport.approvedBy).toBe("approver-1");
expect(result.sideEffects.publishTargets).toEqual(["dashboard", "html-export"]);
});
it("applyDecision publish action from approved sets published state", () => {
const report = makeReport("approved");
const settings: ApprovalSettings = {
approvalRequired: true,
autoPublishOnApproval: false,
approverAgentIds: ["approver-1"],
publishTargets: ["dashboard"],
};
const result = applyDecision(report, makeDecision("publish"), settings, { id: "approver-1", type: "agent" });
expect("error" in result).toBe(false);
if ("error" in result) return;
expect(result.updatedReport.approvalState).toBe("published");
expect(result.updatedReport.status).toBe("published");
expect(result.updatedReport.publishedAt).toBe("2026-05-10T00:00:00.000Z");
});
});

View File

@@ -0,0 +1,109 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginContext } from "@fusion/core";
import type { Report } from "../store/report-types.js";
import { createReportApprovalRoutes } from "../routes/report-approval-routes.js";
function makeReport(overrides: Partial<Report> = {}): Report {
return {
id: "rep_1",
cadence: "daily",
periodStart: "2026-05-01",
periodEnd: "2026-05-01",
title: "Report",
status: "review_complete",
generationStartedAt: "2026-05-01T00:00:00.000Z",
generationCompletedAt: null,
reviewStartedAt: null,
reviewCompletedAt: null,
approvedAt: null,
approvedBy: null,
publishedAt: null,
archivedAt: null,
failureReason: null,
approvalState: "awaiting_approval",
approvalHistory: [],
draftMarkdown: null,
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: null,
metadata: {},
combinedReview: null,
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
...overrides,
};
}
function ctxWithStore(store: { getReport: (id: string) => Report | null; updateReport: (id: string, patch: Partial<Report>) => Report }, settings: Record<string, unknown> = {}): PluginContext {
return {
pluginId: "fusion-plugin-reports",
taskStore: { getDatabase: () => ({}), getReportStore: () => store } as any,
settings,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
} as PluginContext;
}
function route(path: string, method: string) {
return createReportApprovalRoutes().find((entry) => entry.path === path && entry.method === method)!;
}
describe("report approval routes", () => {
it("approve then publish happy path", async () => {
let current = makeReport({ approvalState: "awaiting_approval" });
const store = {
getReport: vi.fn(() => current),
updateReport: vi.fn((_id: string, patch: Partial<Report>) => {
current = { ...current, ...patch };
return current;
}),
};
const ctx = ctxWithStore(store, { approvalRequired: true, autoPublishOnApproval: false, approverAgentIds: ["agent-1"] });
const approve = await route("/reports/:id/approve", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-1" } }, ctx as any) as any;
expect(approve.status).toBe(200);
expect(approve.body.report.approvalState).toBe("approved");
const publish = await route("/reports/:id/publish", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-1" } }, ctx as any) as any;
expect(publish.status).toBe(200);
expect(publish.body.report.approvalState).toBe("published");
});
it("supports reject path", async () => {
const store = {
getReport: vi.fn(() => makeReport()),
updateReport: vi.fn((_id: string, patch: Partial<Report>) => ({ ...makeReport(), ...patch })),
};
const ctx = ctxWithStore(store, { approvalRequired: true, autoPublishOnApproval: false, approverAgentIds: ["agent-1"] });
const reject = await route("/reports/:id/reject", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-1" } }, ctx as any) as any;
expect(reject.status).toBe(200);
expect(reject.body.report.approvalState).toBe("rejected");
});
it("returns 403 for unauthorized approver", async () => {
const store = {
getReport: vi.fn(() => makeReport()),
updateReport: vi.fn(),
};
const ctx = ctxWithStore(store, { approvalRequired: true, autoPublishOnApproval: false, approverAgentIds: ["agent-1"] });
const res = await route("/reports/:id/approve", "POST").handler({ params: { id: "rep_1" }, headers: { "x-fusion-actor-type": "agent", "x-fusion-user": "agent-2" } }, ctx as any) as any;
expect(res.status).toBe(403);
});
it("share-blocks returns 409 before approval and 200 after", async () => {
const current = makeReport({ approvalState: "awaiting_approval", combinedReview: { overallVerdict: "approve", consensusSummary: "ok", mergedHighlights: ["a"], mergedLowlights: [], mergedSuggestions: [], individual: [], failures: [] } });
const store = {
getReport: vi.fn(() => current),
updateReport: vi.fn(),
};
const ctx = ctxWithStore(store);
const locked = await route("/reports/:id/share-blocks", "GET").handler({ params: { id: "rep_1" } }, ctx as any) as any;
expect(locked.status).toBe(409);
const openStore = { ...store, getReport: vi.fn(() => ({ ...current, approvalState: "approved" as const })) };
const openCtx = ctxWithStore(openStore);
const open = await route("/reports/:id/share-blocks", "GET").handler({ params: { id: "rep_1" } }, openCtx as any) as any;
expect(open.status).toBe(200);
expect(open.body).toHaveProperty("plainText");
});
});

View File

@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { buildShareBlocks } from "../share-blocks.js";
import type { Report } from "../store/report-types.js";
function makeReport(): Report {
return {
id: "rep_1",
cadence: "weekly",
periodStart: "2026-05-01",
periodEnd: "2026-05-07",
title: "Weekly <Report> & \"Status\"",
status: "published",
generationStartedAt: "2026-05-01T00:00:00.000Z",
generationCompletedAt: null,
reviewStartedAt: null,
reviewCompletedAt: null,
approvedAt: null,
approvedBy: null,
publishedAt: null,
archivedAt: null,
failureReason: null,
approvalState: "published",
approvalHistory: [],
draftMarkdown: null,
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: null,
metadata: {},
combinedReview: {
overallVerdict: "approve",
consensusSummary: "ok",
mergedHighlights: ["Win <script>alert(1)</script>", "Win 2"],
mergedLowlights: ["Low & bad"],
mergedSuggestions: ["Do \"x\""],
individual: [],
failures: [],
},
createdAt: "2026-05-01T00:00:00.000Z",
updatedAt: "2026-05-01T00:00:00.000Z",
};
}
describe("buildShareBlocks", () => {
it("builds deterministic outputs", () => {
const report = makeReport();
const first = buildShareBlocks(report);
const second = buildShareBlocks(report);
expect(first).toEqual(second);
expect(first.markdown).toContain("## Weekly \\\<Report\\\>");
expect(first.slack).toContain("*Weekly <Report> & \"Status\"*");
});
it("escapes markdown and html", () => {
const blocks = buildShareBlocks(makeReport());
expect(blocks.markdown).toContain("\\<Report\\>");
expect(blocks.emailHtml).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(blocks.emailHtml).toContain("&amp;");
expect(blocks.emailHtml).toContain("&quot;Status&quot;");
});
it("omits empty sections", () => {
const report = makeReport();
report.combinedReview = {
overallVerdict: "approve",
consensusSummary: "ok",
mergedHighlights: [],
mergedLowlights: [],
mergedSuggestions: [],
individual: [],
failures: [],
};
const blocks = buildShareBlocks(report);
expect(blocks.plainText).not.toContain("Wins:");
expect(blocks.markdown).not.toContain("### Wins");
});
it("slack block avoids markdown-only constructs", () => {
const blocks = buildShareBlocks(makeReport());
expect(blocks.slack).not.toContain("#");
expect(blocks.slack).not.toContain("**");
expect(blocks.slack).not.toContain("- ");
});
});