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:
@@ -116,6 +116,39 @@ Indexes:
|
||||
|
||||
`failed` and `archived` are allowed from any non-terminal state. Idempotent transitions (`from === to`) are no-ops.
|
||||
|
||||
### Approval + publish lifecycle (FN-3787)
|
||||
|
||||
A parallel `approvalState` gate now controls human/approver decisions before distribution:
|
||||
|
||||
`review_complete` entry:
|
||||
- `approvalRequired=false, autoPublishOnApproval=false` → `approvalState=approved`, `status=approved`
|
||||
- `approvalRequired=false, autoPublishOnApproval=true` → `approvalState=published`, `status=published`
|
||||
- `approvalRequired=true` → `approvalState=awaiting_approval`, `status=review_complete`
|
||||
|
||||
Decision transitions:
|
||||
- `awaiting_approval --approve--> approved` (or directly `published` when `autoPublishOnApproval=true`)
|
||||
- `awaiting_approval --reject--> rejected`
|
||||
- `approved --publish--> published`
|
||||
|
||||
Backfilled legacy rows use `approvalState=not_required` and are non-actionable.
|
||||
|
||||
Authorization rules:
|
||||
- When `approvalRequired=true` and `approverAgentIds` is non-empty, only listed approver agent IDs may approve/reject/publish.
|
||||
- When `approvalRequired=true` and `approverAgentIds=[]`, any human dashboard user is allowed; agents are not.
|
||||
- `publishTargets` records publish intent metadata when a report reaches `published`.
|
||||
|
||||
### Share-ready summary blocks (FN-3787)
|
||||
|
||||
Approved/published reports can produce deterministic share artifacts via `GET /reports/:id/share-blocks`:
|
||||
- `plainText`: compact paste-ready summary
|
||||
- `markdown`: heading/bullets + report link
|
||||
- `slack`: mrkdwn-friendly summary
|
||||
- `emailHtml`: inline-styled HTML snippet for email clients
|
||||
|
||||
`share-blocks` is intentionally locked (409) until `approvalState` is `approved` or `published`.
|
||||
|
||||
> Email HTML styling exemption: `emailHtml` deliberately uses inline style attributes and hardcoded hex colors for email-client compatibility; dashboard design-token CSS rules do not apply to this serialized output format.
|
||||
|
||||
### ReportStore API
|
||||
|
||||
- `createReport(input)`
|
||||
|
||||
164
plugins/fusion-plugin-reports/src/__tests__/approval.test.ts
Normal file
164
plugins/fusion-plugin-reports/src/__tests__/approval.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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("<script>alert(1)</script>");
|
||||
expect(blocks.emailHtml).toContain("&");
|
||||
expect(blocks.emailHtml).toContain(""Status"");
|
||||
});
|
||||
|
||||
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("- ");
|
||||
});
|
||||
});
|
||||
97
plugins/fusion-plugin-reports/src/approval.ts
Normal file
97
plugins/fusion-plugin-reports/src/approval.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Report, ReportStatus } from "./store/report-types.js";
|
||||
|
||||
export type ApprovalState = "not_required" | "awaiting_approval" | "approved" | "rejected" | "published";
|
||||
export type ApprovalAction = "approve" | "reject" | "publish";
|
||||
|
||||
export interface ApprovalDecision {
|
||||
decidedBy: string;
|
||||
decidedAt: string;
|
||||
note?: string;
|
||||
action: ApprovalAction;
|
||||
}
|
||||
|
||||
export interface ApprovalSettings {
|
||||
approvalRequired: boolean;
|
||||
autoPublishOnApproval: boolean;
|
||||
approverAgentIds: string[];
|
||||
publishTargets: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalActor {
|
||||
id: string;
|
||||
type: "human" | "agent";
|
||||
}
|
||||
|
||||
export type ApprovalError = "invalid_transition" | "unauthorized";
|
||||
|
||||
export function initializeApprovalState(reportStatus: ReportStatus, settings: ApprovalSettings): ApprovalState {
|
||||
if (reportStatus !== "review_complete") return "not_required";
|
||||
if (settings.approvalRequired) return "awaiting_approval";
|
||||
return settings.autoPublishOnApproval ? "published" : "approved";
|
||||
}
|
||||
|
||||
export function nextApprovalState(
|
||||
current: ApprovalState,
|
||||
action: ApprovalAction,
|
||||
settings: ApprovalSettings,
|
||||
actor: ApprovalActor,
|
||||
): { next: ApprovalState } | { error: ApprovalError } {
|
||||
if (!isAuthorized(settings, actor)) return { error: "unauthorized" };
|
||||
if (current === "awaiting_approval" && action === "approve") {
|
||||
return { next: settings.autoPublishOnApproval ? "published" : "approved" };
|
||||
}
|
||||
if (current === "awaiting_approval" && action === "reject") return { next: "rejected" };
|
||||
if (current === "approved" && action === "publish") return { next: "published" };
|
||||
return { error: "invalid_transition" };
|
||||
}
|
||||
|
||||
export function applyDecision(
|
||||
report: Report,
|
||||
decision: ApprovalDecision,
|
||||
settings: ApprovalSettings,
|
||||
actor: ApprovalActor,
|
||||
):
|
||||
| { error: ApprovalError }
|
||||
| {
|
||||
updatedReport: Partial<Report>;
|
||||
sideEffects: { publishTargets: string[] };
|
||||
} {
|
||||
const transition = nextApprovalState(report.approvalState, decision.action, settings, actor);
|
||||
if ("error" in transition) return transition;
|
||||
|
||||
const approvalHistory = [...report.approvalHistory, decision];
|
||||
const update: Partial<Report> = {
|
||||
approvalState: transition.next,
|
||||
approvalHistory,
|
||||
};
|
||||
|
||||
if (transition.next === "approved") {
|
||||
update.status = "approved";
|
||||
update.approvedAt = decision.decidedAt;
|
||||
update.approvedBy = decision.decidedBy;
|
||||
}
|
||||
|
||||
if (transition.next === "published") {
|
||||
update.status = "published";
|
||||
update.publishedAt = decision.decidedAt;
|
||||
if (decision.action === "approve") {
|
||||
update.approvedAt = decision.decidedAt;
|
||||
update.approvedBy = decision.decidedBy;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedReport: update,
|
||||
sideEffects: {
|
||||
publishTargets: transition.next === "published" ? [...settings.publishTargets] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isAuthorized(settings: ApprovalSettings, actor: ApprovalActor): boolean {
|
||||
if (!settings.approvalRequired) return true;
|
||||
const approvers = settings.approverAgentIds;
|
||||
if (approvers.length === 0) return actor.type === "human";
|
||||
if (actor.type !== "agent") return false;
|
||||
return approvers.includes(actor.id);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReportRecord } from "./types.js";
|
||||
import type { ShareBlocks } from "../share-blocks.js";
|
||||
|
||||
const BASE = "/api/plugins/reports";
|
||||
|
||||
@@ -51,3 +52,34 @@ export function getReportPreviewHtml(id: string, projectId?: string): Promise<st
|
||||
export function getReportExportUrl(id: string, projectId?: string): string {
|
||||
return `${BASE}/reports/${encodeURIComponent(id)}/export.html${qp({ projectId })}`;
|
||||
}
|
||||
|
||||
export async function approveReport(id: string, note?: string): Promise<ReportRecord> {
|
||||
const data = await request<{ report: ReportRecord }>(`/reports/${encodeURIComponent(id)}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(note ? { note } : {}),
|
||||
});
|
||||
return data.report;
|
||||
}
|
||||
|
||||
export async function rejectReport(id: string, note?: string): Promise<ReportRecord> {
|
||||
const data = await request<{ report: ReportRecord }>(`/reports/${encodeURIComponent(id)}/reject`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(note ? { note } : {}),
|
||||
});
|
||||
return data.report;
|
||||
}
|
||||
|
||||
export async function publishReport(id: string): Promise<ReportRecord> {
|
||||
const data = await request<{ report: ReportRecord }>(`/reports/${encodeURIComponent(id)}/publish`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
return data.report;
|
||||
}
|
||||
|
||||
export async function getShareBlocks(id: string): Promise<ShareBlocks> {
|
||||
return request<ShareBlocks>(`/reports/${encodeURIComponent(id)}/share-blocks`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
.report-approval-panel {
|
||||
border-top: var(--btn-border-width) solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
|
||||
.report-approval-panel__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.report-approval-panel__header h4 {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-approval-panel__note {
|
||||
min-height: calc(var(--space-2xl) * 2);
|
||||
}
|
||||
|
||||
.card-status-badge--awaiting_approval {
|
||||
background: color-mix(in srgb, var(--color-warning) 20%, transparent);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.card-status-badge--approved,
|
||||
.card-status-badge--published {
|
||||
background: color-mix(in srgb, var(--color-success) 20%, transparent);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.card-status-badge--rejected {
|
||||
background: color-mix(in srgb, var(--color-error) 20%, transparent);
|
||||
color: var(--color-error);
|
||||
}
|
||||
|
||||
.report-approval-panel__actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.report-approval-panel__history {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
margin: 0;
|
||||
padding-left: var(--space-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.report-approval-panel__actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { approveReport, publishReport, rejectReport } from "../api.js";
|
||||
import type { ReportRecord } from "../types.js";
|
||||
import "./ReportApprovalPanel.css";
|
||||
|
||||
interface Props {
|
||||
report: ReportRecord;
|
||||
onReportChange: (report: ReportRecord) => void;
|
||||
}
|
||||
|
||||
export function ReportApprovalPanel({ report, onReportChange }: Props) {
|
||||
const [note, setNote] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const approvalState = report.approvalState ?? "not_required";
|
||||
const canApprove = approvalState === "awaiting_approval";
|
||||
const canPublish = approvalState === "approved";
|
||||
|
||||
const history = useMemo(() => [...(report.approvalHistory ?? [])].reverse(), [report.approvalHistory]);
|
||||
|
||||
async function run(action: "approve" | "reject" | "publish") {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const next = action === "approve"
|
||||
? await approveReport(report.id, note)
|
||||
: action === "reject"
|
||||
? await rejectReport(report.id, note)
|
||||
: await publishReport(report.id);
|
||||
onReportChange(next);
|
||||
setNote("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <section className="report-approval-panel">
|
||||
<div className="report-approval-panel__header">
|
||||
<h4>Approval</h4>
|
||||
<span className={`card-status-badge card-status-badge--${approvalState}`}>{approvalState}</span>
|
||||
</div>
|
||||
{canApprove ? <>
|
||||
<textarea className="input report-approval-panel__note" value={note} onChange={(event) => setNote(event.target.value)} placeholder="Optional note" />
|
||||
<div className="report-approval-panel__actions">
|
||||
<button className="btn btn-primary" disabled={busy} onClick={() => run("approve")}>Approve</button>
|
||||
<button className="btn btn-danger" disabled={busy} onClick={() => run("reject")}>Reject</button>
|
||||
</div>
|
||||
</> : null}
|
||||
{canPublish ? <div className="report-approval-panel__actions"><button className="btn btn-primary" disabled={busy} onClick={() => run("publish")}>Publish</button></div> : null}
|
||||
{error ? <div className="form-error">{error}</div> : null}
|
||||
<ul className="report-approval-panel__history">
|
||||
{history.map((item, index) => <li key={`${item.decidedAt}-${index}`}>{item.action} by {item.decidedBy} at {item.decidedAt}{item.note ? ` — ${item.note}` : ""}</li>)}
|
||||
</ul>
|
||||
</section>;
|
||||
}
|
||||
@@ -1,23 +1,29 @@
|
||||
import { useMemo, useRef } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { getReportExportUrl } from "../api.js";
|
||||
import { useReportPreview } from "../useReportPreview.js";
|
||||
import type { ReportRecord } from "../types.js";
|
||||
import { ReportApprovalPanel } from "./ReportApprovalPanel.js";
|
||||
import { ShareBlocksPanel } from "./ShareBlocksPanel.js";
|
||||
|
||||
const SECTION_IDS = ["summary", "system-wins", "system-highlights", "system-lowlights", "system-proposals", "system-deep-dives", "agent-card", "data-coverage", "review-panel"];
|
||||
|
||||
export function ReportDetailPanel({ report, projectId }: { report?: ReportRecord; projectId?: string }) {
|
||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const { html, loading, error } = useReportPreview(report?.id, projectId);
|
||||
const [currentReport, setCurrentReport] = useState<ReportRecord | undefined>(report);
|
||||
useEffect(() => setCurrentReport(report), [report]);
|
||||
const { html, loading, error } = useReportPreview(currentReport?.id, projectId);
|
||||
const sections = useMemo(() => SECTION_IDS, []);
|
||||
if (!report) return <div className="reports-detail card">Select a report.</div>;
|
||||
if (!currentReport) return <div className="reports-detail card">Select a report.</div>;
|
||||
return <div className="reports-detail card">
|
||||
<div className="reports-detail-header"><h3>{report.title}</h3><a className="btn btn-sm" href={getReportExportUrl(report.id, projectId)} download>Download standalone HTML</a></div>
|
||||
<div className="reports-detail-meta">{report.cadence} • {report.status} • {report.periodStart} → {report.periodEnd}</div>
|
||||
<div className="reports-detail-header"><h3>{currentReport.title}</h3><a className="btn btn-sm" href={getReportExportUrl(currentReport.id, projectId)} download>Download standalone HTML</a></div>
|
||||
<div className="reports-detail-meta">{currentReport.cadence} • {currentReport.status} • {currentReport.periodStart} → {currentReport.periodEnd}</div>
|
||||
<div className="reports-detail-body">
|
||||
<nav className="reports-detail-sections">{sections.map((section) => <button key={section} className="btn btn-sm" onClick={() => frameRef.current?.contentWindow?.document.querySelector(`[data-section="${section}"]`)?.scrollIntoView()}>{section}</button>)}</nav>
|
||||
{loading ? <div>Loading preview...</div> : null}
|
||||
{error ? <div>{error}</div> : null}
|
||||
<iframe ref={frameRef} sandbox="allow-same-origin" srcDoc={html} title="Report preview" />
|
||||
</div>
|
||||
<ReportApprovalPanel report={currentReport} onReportChange={setCurrentReport} />
|
||||
<ShareBlocksPanel report={currentReport} />
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.share-blocks-panel {
|
||||
border-top: var(--btn-border-width) solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding-top: var(--space-md);
|
||||
}
|
||||
|
||||
.share-blocks-panel__tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.share-blocks-panel__content {
|
||||
min-height: calc(var(--space-2xl) * 4);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.share-blocks-panel__locked {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.share-blocks-panel__tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getShareBlocks } from "../api.js";
|
||||
import type { ReportRecord } from "../types.js";
|
||||
import type { ShareBlocks } from "../../share-blocks.js";
|
||||
import "./ShareBlocksPanel.css";
|
||||
|
||||
const TABS: Array<{ key: keyof ShareBlocks; label: string }> = [
|
||||
{ key: "plainText", label: "Plain Text" },
|
||||
{ key: "markdown", label: "Markdown" },
|
||||
{ key: "slack", label: "Slack" },
|
||||
{ key: "emailHtml", label: "Email HTML" },
|
||||
];
|
||||
|
||||
export function ShareBlocksPanel({ report }: { report: ReportRecord }) {
|
||||
const [active, setActive] = useState<keyof ShareBlocks>("plainText");
|
||||
const [data, setData] = useState<ShareBlocks | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLocked(false);
|
||||
setData(null);
|
||||
getShareBlocks(report.id).then(setData).catch((error: Error) => {
|
||||
if (error.message.includes("409")) setLocked(true);
|
||||
});
|
||||
}, [report.id]);
|
||||
|
||||
if (locked) return <section className="share-blocks-panel"><p className="share-blocks-panel__locked">Share blocks unlock after the report is approved.</p></section>;
|
||||
if (!data) return <section className="share-blocks-panel"><p>Loading share blocks…</p></section>;
|
||||
|
||||
const value = data[active];
|
||||
return <section className="share-blocks-panel">
|
||||
<div className="share-blocks-panel__tabs">
|
||||
{TABS.map((tab) => <button key={tab.key} className={`btn btn-sm ${active === tab.key ? "btn-primary" : ""}`} onClick={() => setActive(tab.key)}>{tab.label}</button>)}
|
||||
</div>
|
||||
<textarea className="input share-blocks-panel__content" readOnly value={value} />
|
||||
<button className="btn btn-sm" onClick={async () => {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1000);
|
||||
}}>{copied ? "Copied" : "Copy"}</button>
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ReportApprovalPanel } from "../ReportApprovalPanel.js";
|
||||
|
||||
vi.mock("../../api.js", () => ({
|
||||
approveReport: vi.fn(async () => ({ ...baseReport, approvalState: "approved" })),
|
||||
rejectReport: vi.fn(async () => ({ ...baseReport, approvalState: "rejected" })),
|
||||
publishReport: vi.fn(async () => ({ ...baseReport, approvalState: "published" })),
|
||||
}));
|
||||
|
||||
const baseReport: any = {
|
||||
id: "rep_1",
|
||||
approvalState: "awaiting_approval",
|
||||
approvalHistory: [],
|
||||
status: "review_complete",
|
||||
};
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("ReportApprovalPanel", () => {
|
||||
it("renders actions for awaiting approval and posts approve", async () => {
|
||||
const onReportChange = vi.fn();
|
||||
render(<ReportApprovalPanel report={baseReport} onReportChange={onReportChange} />);
|
||||
fireEvent.click(screen.getByText("Approve"));
|
||||
await waitFor(() => expect(onReportChange).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("renders publish action for approved", () => {
|
||||
render(<ReportApprovalPanel report={{ ...baseReport, approvalState: "approved" }} onReportChange={vi.fn()} />);
|
||||
expect(screen.getByText("Publish")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("read-only for rejected", () => {
|
||||
render(<ReportApprovalPanel report={{ id: "rep_1", status: "review_complete", approvalState: "rejected", approvalHistory: [{ action: "reject", decidedAt: "now", decidedBy: "u" }] } as any} onReportChange={vi.fn()} />);
|
||||
expect(screen.queryByText("Publish")).toBeNull();
|
||||
expect(screen.getByText(/reject by/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ShareBlocksPanel } from "../ShareBlocksPanel.js";
|
||||
|
||||
const getShareBlocks = vi.fn();
|
||||
vi.mock("../../api.js", () => ({ getShareBlocks: (...args: unknown[]) => getShareBlocks(...args) }));
|
||||
|
||||
describe("ShareBlocksPanel", () => {
|
||||
it("renders tabs and copies selected block", async () => {
|
||||
getShareBlocks.mockResolvedValue({ plainText: "a", markdown: "b", slack: "c", emailHtml: "d" });
|
||||
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
|
||||
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
|
||||
await screen.findByText("Plain Text");
|
||||
fireEvent.click(screen.getByText("Markdown"));
|
||||
fireEvent.click(screen.getByText("Copy"));
|
||||
await waitFor(() => expect(navigator.clipboard.writeText).toHaveBeenCalledWith("b"));
|
||||
});
|
||||
|
||||
it("shows locked message on 409", async () => {
|
||||
getShareBlocks.mockRejectedValue(new Error("409 Conflict"));
|
||||
render(<ShareBlocksPanel report={{ id: "rep_1" } as any} />);
|
||||
await screen.findByText(/unlock after the report is approved/i);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,21 @@
|
||||
import type { PluginContext } from "@fusion/core";
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { initializeApprovalState } from "./approval.js";
|
||||
import { runReviewPanel } from "./review-panel.js";
|
||||
import { ensureReportSchema } from "./report-schema.js";
|
||||
import { createReportApprovalRoutes } from "./routes/report-approval-routes.js";
|
||||
import { createReportExportRoutes } from "./routes/report-export-routes.js";
|
||||
import { createReportListRoutes } from "./routes/report-list-routes.js";
|
||||
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
|
||||
import {
|
||||
getApprovalRequired,
|
||||
getApproverAgentIds,
|
||||
getAutoPublishOnApproval,
|
||||
getPublishTargets,
|
||||
settingsSchema,
|
||||
} from "./settings.js";
|
||||
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
|
||||
import { ReportStore } from "./store/report-store.js";
|
||||
import { settingsSchema } from "./settings.js";
|
||||
export { ReportsDashboardView } from "./dashboard-view.js";
|
||||
|
||||
const plugin = definePlugin({
|
||||
@@ -24,7 +32,7 @@ const plugin = definePlugin({
|
||||
hooks: {
|
||||
onSchemaInit: ensureReportSchema,
|
||||
},
|
||||
routes: [...createReportListRoutes(), ...createReportExportRoutes()],
|
||||
routes: [...createReportListRoutes(), ...createReportExportRoutes(), ...createReportApprovalRoutes()],
|
||||
dashboardViews: [
|
||||
{
|
||||
viewId: "reports",
|
||||
@@ -87,7 +95,26 @@ export async function runGeneratedReportReview(input: RunGeneratedReportReviewIn
|
||||
cwd: input.cwd,
|
||||
}, ctx);
|
||||
|
||||
store.attachReview(report.id, combinedReview);
|
||||
const reviewed = store.attachReview(report.id, combinedReview);
|
||||
|
||||
const nextApprovalState = initializeApprovalState(reviewed.status, {
|
||||
approvalRequired: getApprovalRequired(ctx.settings),
|
||||
autoPublishOnApproval: getAutoPublishOnApproval(ctx.settings),
|
||||
approverAgentIds: getApproverAgentIds(ctx.settings),
|
||||
publishTargets: getPublishTargets(ctx.settings),
|
||||
});
|
||||
|
||||
if (nextApprovalState !== "not_required") {
|
||||
const now = new Date().toISOString();
|
||||
store.updateReport(report.id, {
|
||||
approvalState: nextApprovalState,
|
||||
...(nextApprovalState === "approved"
|
||||
? { status: "approved", approvedAt: now, approvedBy: "system" }
|
||||
: {}),
|
||||
...(nextApprovalState === "published" ? { status: "published", publishedAt: now } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
return combinedReview;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@ function createRecord(overrides: Partial<Report> = {}, metadata: Record<string,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: "not_required",
|
||||
approvalHistory: [],
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
combinedReview: null,
|
||||
|
||||
@@ -19,6 +19,8 @@ function createRecord(metadata: Record<string, unknown> = {}): Report {
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: "not_required",
|
||||
approvalHistory: [],
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { Database } from "@fusion/core";
|
||||
|
||||
function addColumnIfMissing(db: Database, table: string, column: string, ddl: string): boolean {
|
||||
const columns = db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (columns.some((entry) => entry.name === column)) return false;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function ensureReportSchema(db: Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS reports (
|
||||
@@ -18,6 +25,8 @@ export function ensureReportSchema(db: Database): void {
|
||||
publishedAt TEXT,
|
||||
archivedAt TEXT,
|
||||
failureReason TEXT,
|
||||
approval_state TEXT NOT NULL DEFAULT 'not_required',
|
||||
approval_history TEXT NOT NULL DEFAULT '[]',
|
||||
draftMarkdown TEXT,
|
||||
renderedHtmlPath TEXT,
|
||||
rendered_html TEXT,
|
||||
@@ -38,12 +47,23 @@ export function ensureReportSchema(db: Database): void {
|
||||
ON reports(periodStart, periodEnd, id);
|
||||
`);
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(reports)").all() as Array<{ name: string }>;
|
||||
const names = new Set(columns.map((column) => column.name));
|
||||
if (!names.has("rendered_html")) {
|
||||
db.exec("ALTER TABLE reports ADD COLUMN rendered_html TEXT");
|
||||
}
|
||||
if (!names.has("rendered_html_generated_at")) {
|
||||
db.exec("ALTER TABLE reports ADD COLUMN rendered_html_generated_at TEXT");
|
||||
}
|
||||
addColumnIfMissing(db, "reports", "rendered_html", "TEXT");
|
||||
addColumnIfMissing(db, "reports", "rendered_html_generated_at", "TEXT");
|
||||
addColumnIfMissing(db, "reports", "approval_state", "TEXT NOT NULL DEFAULT 'not_required'");
|
||||
addColumnIfMissing(db, "reports", "approval_history", "TEXT NOT NULL DEFAULT '[]'");
|
||||
|
||||
db.exec(`
|
||||
UPDATE reports
|
||||
SET approval_state = 'published',
|
||||
publishedAt = COALESCE(publishedAt, generationCompletedAt)
|
||||
WHERE status = 'published';
|
||||
|
||||
UPDATE reports
|
||||
SET approval_state = 'published'
|
||||
WHERE status = 'approved';
|
||||
|
||||
UPDATE reports
|
||||
SET approval_state = 'not_required'
|
||||
WHERE status = 'review_complete';
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ function report(overrides: Partial<Report> = {}): Report {
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: "not_required",
|
||||
approvalHistory: [],
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { applyDecision, type ApprovalActor, type ApprovalDecision, type ApprovalSettings } from "../approval.js";
|
||||
import { getApprovalRequired, getApproverAgentIds, getAutoPublishOnApproval, getPublishTargets } from "../settings.js";
|
||||
import { buildShareBlocks } from "../share-blocks.js";
|
||||
import { ReportStore } from "../store/report-store.js";
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
body?: Record<string, unknown>;
|
||||
headers?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
const reportStoreCache = new WeakMap<object, ReportStore>();
|
||||
|
||||
function getStore(ctx: PluginContext): ReportStore {
|
||||
const taskStoreWithReports = ctx.taskStore as PluginContext["taskStore"] & { getReportStore?: () => ReportStore };
|
||||
if (typeof taskStoreWithReports.getReportStore === "function") return taskStoreWithReports.getReportStore();
|
||||
const key = ctx.taskStore as object;
|
||||
const cached = reportStoreCache.get(key);
|
||||
if (cached) return cached;
|
||||
const store = new ReportStore(ctx.taskStore.getDatabase());
|
||||
reportStoreCache.set(key, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
function settingsFromContext(ctx: PluginContext): ApprovalSettings {
|
||||
return {
|
||||
approvalRequired: getApprovalRequired(ctx.settings),
|
||||
autoPublishOnApproval: getAutoPublishOnApproval(ctx.settings),
|
||||
approverAgentIds: getApproverAgentIds(ctx.settings),
|
||||
publishTargets: getPublishTargets(ctx.settings),
|
||||
};
|
||||
}
|
||||
|
||||
function actorFromRequest(request: RouteRequest, ctx: PluginContext): ApprovalActor {
|
||||
const actorType = request.headers?.["x-fusion-actor-type"];
|
||||
const actorId = request.headers?.["x-fusion-user"]
|
||||
?? (typeof request.body?.decidedBy === "string" ? request.body.decidedBy : undefined)
|
||||
?? "unknown";
|
||||
if (!request.headers?.["x-fusion-user"] && !request.body?.decidedBy) {
|
||||
ctx.logger.warn("reports approval route missing actor identity; using unknown");
|
||||
}
|
||||
return { id: actorId, type: actorType === "agent" ? "agent" : "human" };
|
||||
}
|
||||
|
||||
function decisionFromRequest(request: RouteRequest, action: ApprovalDecision["action"], actor: ApprovalActor): ApprovalDecision {
|
||||
return {
|
||||
action,
|
||||
decidedBy: actor.id,
|
||||
decidedAt: new Date().toISOString(),
|
||||
note: typeof request.body?.note === "string" && request.body.note.trim().length > 0 ? request.body.note.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function notFound(id: string): PluginRouteResponse {
|
||||
return { status: 404, body: { error: `Report ${id} not found` } };
|
||||
}
|
||||
|
||||
export function createReportApprovalRoutes(): PluginRouteDefinition[] {
|
||||
const mutate = (action: ApprovalDecision["action"]) => async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const reportId = request.params.id;
|
||||
const store = getStore(ctx);
|
||||
const report = store.getReport(reportId);
|
||||
if (!report) return notFound(reportId);
|
||||
|
||||
const actor = actorFromRequest(request, ctx);
|
||||
const settings = settingsFromContext(ctx);
|
||||
const decision = decisionFromRequest(request, action, actor);
|
||||
const result = applyDecision(report, decision, settings, actor);
|
||||
if ("error" in result) {
|
||||
return { status: result.error === "unauthorized" ? 403 : 409, body: { error: result.error } };
|
||||
}
|
||||
|
||||
const updated = store.updateReport(reportId, result.updatedReport);
|
||||
return { status: 200, body: { report: updated, sideEffects: result.sideEffects } };
|
||||
};
|
||||
|
||||
return [
|
||||
{ method: "POST", path: "/reports/:id/approve", handler: mutate("approve") },
|
||||
{ method: "POST", path: "/reports/:id/reject", handler: mutate("reject") },
|
||||
{ method: "POST", path: "/reports/:id/publish", handler: mutate("publish") },
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports/:id/share-blocks",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const reportId = request.params.id;
|
||||
const report = getStore(ctx).getReport(reportId);
|
||||
if (!report) return notFound(reportId);
|
||||
if (!(report.approvalState === "approved" || report.approvalState === "published")) {
|
||||
return { status: 409, body: { error: "Share blocks unlock after approval" } };
|
||||
}
|
||||
return { status: 200, body: buildShareBlocks(report) };
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
82
plugins/fusion-plugin-reports/src/share-blocks.ts
Normal file
82
plugins/fusion-plugin-reports/src/share-blocks.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import type { Report } from "./store/report-types.js";
|
||||
|
||||
const MAX_ITEMS_PER_SECTION = 5;
|
||||
const MAX_BLOCK_LENGTH = 1500;
|
||||
|
||||
export interface ShareBlocks {
|
||||
plainText: string;
|
||||
markdown: string;
|
||||
slack: string;
|
||||
emailHtml: string;
|
||||
}
|
||||
|
||||
function sliceWithEllipsis(items: string[]): string[] {
|
||||
if (items.length <= MAX_ITEMS_PER_SECTION) return items;
|
||||
return [...items.slice(0, MAX_ITEMS_PER_SECTION), "…"];
|
||||
}
|
||||
|
||||
function getSections(report: Report): Array<{ heading: string; items: string[] }> {
|
||||
const review = report.combinedReview;
|
||||
const sections: Array<{ heading: string; items: string[] }> = [];
|
||||
const wins = review?.mergedHighlights ?? [];
|
||||
const highlights = review?.mergedSuggestions ?? [];
|
||||
const lowlights = review?.mergedLowlights ?? [];
|
||||
if (wins.length > 0) sections.push({ heading: "Wins", items: sliceWithEllipsis(wins) });
|
||||
if (highlights.length > 0) sections.push({ heading: "Highlights", items: sliceWithEllipsis(highlights) });
|
||||
if (lowlights.length > 0) sections.push({ heading: "Lowlights", items: sliceWithEllipsis(lowlights) });
|
||||
return sections;
|
||||
}
|
||||
|
||||
function trimBlock(text: string): string {
|
||||
return text.length <= MAX_BLOCK_LENGTH ? text : `${text.slice(0, MAX_BLOCK_LENGTH - 1)}…`;
|
||||
}
|
||||
|
||||
export function escapeMarkdown(value: string): string {
|
||||
return value.replace(/[\\`*_{}\[\]()#+\-.!|<>]/g, "\\$&");
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
/** Builds deterministic share-ready text blocks. */
|
||||
export function buildShareBlocks(report: Report): ShareBlocks {
|
||||
const sections = getSections(report);
|
||||
const heading = `${report.title}\n${report.periodStart} → ${report.periodEnd}`;
|
||||
const plainText = trimBlock([
|
||||
heading,
|
||||
...sections.map((section) => `${section.heading}:\n${section.items.map((item) => `- ${item}`).join("\n")}`),
|
||||
].join("\n\n"));
|
||||
|
||||
const reportUrl = `/reports/${encodeURIComponent(report.id)}`;
|
||||
const markdown = trimBlock([
|
||||
`## ${escapeMarkdown(report.title)}`,
|
||||
`Period: ${escapeMarkdown(report.periodStart)} → ${escapeMarkdown(report.periodEnd)}`,
|
||||
...sections.map((section) => `### ${section.heading}\n${section.items.map((item) => `- ${escapeMarkdown(item)}`).join("\n")}`),
|
||||
`[Open report](${reportUrl})`,
|
||||
].join("\n\n"));
|
||||
|
||||
const slack = trimBlock([
|
||||
`*${report.title}*`,
|
||||
`${report.periodStart} → ${report.periodEnd}`,
|
||||
...sections.map((section) => `*${section.heading}*\n${section.items.map((item) => `• ${item}`).join("\n")}`),
|
||||
`<${reportUrl}|Open report>`,
|
||||
].join("\n\n"));
|
||||
|
||||
// Note: emailHtml uses hardcoded hex/inline styles for email-client compatibility — design-token rule does not apply here.
|
||||
const emailHtml = [
|
||||
`<div style="font-family:Arial,sans-serif;color:#1f2328;line-height:1.5;">`,
|
||||
`<h2 style="margin:0 0 12px;color:#5B8DEF;">${escapeHtml(report.title)}</h2>`,
|
||||
`<p style="margin:0 0 12px;">Period: ${escapeHtml(report.periodStart)} → ${escapeHtml(report.periodEnd)}</p>`,
|
||||
...sections.map((section) => `<h3 style="margin:12px 0 6px;">${escapeHtml(section.heading)}</h3><ul style="margin:0 0 12px;padding-left:20px;">${section.items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul>`),
|
||||
`<p style="margin:0;"><a href="${escapeHtml(reportUrl)}" style="color:#5B8DEF;">Open report</a></p>`,
|
||||
`</div>`,
|
||||
].join("");
|
||||
|
||||
return { plainText, markdown, slack, emailHtml };
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Database } from "@fusion/core";
|
||||
import type { ApprovalDecision, ApprovalState } from "../approval.js";
|
||||
import type { CombinedReview } from "../review-types.js";
|
||||
import {
|
||||
type Report,
|
||||
@@ -27,6 +28,8 @@ interface ReportRow {
|
||||
publishedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
failureReason: string | null;
|
||||
approval_state: ApprovalState;
|
||||
approval_history: string;
|
||||
draftMarkdown: string | null;
|
||||
renderedHtmlPath: string | null;
|
||||
rendered_html: string | null;
|
||||
@@ -76,6 +79,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
approvalState: "not_required",
|
||||
approvalHistory: [],
|
||||
draftMarkdown: input.draftMarkdown ?? null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
@@ -92,11 +97,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
id, cadence, periodStart, periodEnd, title, status,
|
||||
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
|
||||
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
|
||||
approval_state, approval_history,
|
||||
draftMarkdown, renderedHtmlPath, rendered_html, rendered_html_generated_at, metadataJson, combinedReviewJson, createdAt, updatedAt
|
||||
) VALUES (
|
||||
@id, @cadence, @periodStart, @periodEnd, @title, @status,
|
||||
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
|
||||
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
|
||||
@approvalState, @approvalHistory,
|
||||
@draftMarkdown, @renderedHtmlPath, @renderedHtml, @renderedHtmlGeneratedAt, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
|
||||
)
|
||||
`).run(this.toDbParams(report, true));
|
||||
@@ -164,6 +171,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
renderedHtml: patch.renderedHtml ?? current.renderedHtml,
|
||||
renderedHtmlGeneratedAt: patch.renderedHtmlGeneratedAt ?? current.renderedHtmlGeneratedAt,
|
||||
failureReason: patch.failureReason ?? current.failureReason,
|
||||
approvalState: patch.approvalState ?? current.approvalState,
|
||||
approvalHistory: patch.approvalHistory ?? current.approvalHistory,
|
||||
status: patch.status ?? current.status,
|
||||
approvedAt: patch.approvedAt ?? current.approvedAt,
|
||||
approvedBy: patch.approvedBy ?? current.approvedBy,
|
||||
publishedAt: patch.publishedAt ?? current.publishedAt,
|
||||
reviewCompletedAt: patch.reviewCompletedAt ?? current.reviewCompletedAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
@@ -269,6 +283,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
publishedAt: row.publishedAt,
|
||||
archivedAt: row.archivedAt,
|
||||
failureReason: row.failureReason,
|
||||
approvalState: row.approval_state,
|
||||
approvalHistory: this.parseApprovalHistory(row.approval_history),
|
||||
draftMarkdown: row.draftMarkdown,
|
||||
renderedHtmlPath: row.renderedHtmlPath,
|
||||
renderedHtml: row.rendered_html,
|
||||
@@ -297,6 +313,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
publishedAt = @publishedAt,
|
||||
archivedAt = @archivedAt,
|
||||
failureReason = @failureReason,
|
||||
approval_state = @approvalState,
|
||||
approval_history = @approvalHistory,
|
||||
draftMarkdown = @draftMarkdown,
|
||||
renderedHtmlPath = @renderedHtmlPath,
|
||||
rendered_html = @renderedHtml,
|
||||
@@ -329,6 +347,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
publishedAt: report.publishedAt,
|
||||
archivedAt: report.archivedAt,
|
||||
failureReason: report.failureReason,
|
||||
approvalState: report.approvalState,
|
||||
approvalHistory: JSON.stringify(report.approvalHistory ?? []),
|
||||
draftMarkdown: report.draftMarkdown,
|
||||
renderedHtmlPath: report.renderedHtmlPath,
|
||||
renderedHtml: report.renderedHtml,
|
||||
@@ -357,4 +377,14 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private parseApprovalHistory(json: string | null): ApprovalDecision[] {
|
||||
if (!json) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(json);
|
||||
return Array.isArray(parsed) ? parsed as ApprovalDecision[] : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ApprovalDecision, ApprovalState } from "../approval.js";
|
||||
import type { CombinedReview } from "../review-types.js";
|
||||
|
||||
export type ReportCadence = "daily" | "weekly" | "monthly" | "quarterly" | "manual";
|
||||
@@ -28,6 +29,8 @@ export interface Report {
|
||||
publishedAt: string | null;
|
||||
archivedAt: string | null;
|
||||
failureReason: string | null;
|
||||
approvalState: ApprovalState;
|
||||
approvalHistory: ApprovalDecision[];
|
||||
draftMarkdown: string | null;
|
||||
renderedHtmlPath: string | null;
|
||||
renderedHtml: string | null;
|
||||
@@ -47,7 +50,7 @@ export interface ReportCreateInput {
|
||||
draftMarkdown?: string;
|
||||
}
|
||||
|
||||
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "renderedHtml" | "renderedHtmlGeneratedAt" | "metadata" | "failureReason">>;
|
||||
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "renderedHtml" | "renderedHtmlGeneratedAt" | "metadata" | "failureReason" | "approvalState" | "approvalHistory" | "status" | "approvedAt" | "approvedBy" | "publishedAt" | "reviewCompletedAt">>;
|
||||
|
||||
export interface ReportListFilter {
|
||||
cadence?: ReportCadence;
|
||||
|
||||
Reference in New Issue
Block a user