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 73f0318160
commit 06daea7e3d
23 changed files with 1063 additions and 17 deletions

View File

@@ -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)`

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("- ");
});
});

View 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);
}

View File

@@ -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`);
}

View File

@@ -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;
}
}

View File

@@ -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>;
}

View File

@@ -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>;
}

View File

@@ -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;
}
}

View File

@@ -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>;
}

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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;
}

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';
`);
}

View File

@@ -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,

View File

@@ -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) };
},
},
];
}

View 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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
/** 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 };
}

View File

@@ -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 [];
}
}
}

View File

@@ -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;