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:
5
.changeset/fn-3787-reports-approval-share.md
Normal file
5
.changeset/fn-3787-reports-approval-share.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Reports plugin: add human approval/publish workflow and share-ready summary blocks (plain text, Markdown, Slack, email HTML) to the dashboard report detail viewer.
|
||||||
@@ -116,6 +116,39 @@ Indexes:
|
|||||||
|
|
||||||
`failed` and `archived` are allowed from any non-terminal state. Idempotent transitions (`from === to`) are no-ops.
|
`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
|
### ReportStore API
|
||||||
|
|
||||||
- `createReport(input)`
|
- `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 { ReportRecord } from "./types.js";
|
||||||
|
import type { ShareBlocks } from "../share-blocks.js";
|
||||||
|
|
||||||
const BASE = "/api/plugins/reports";
|
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 {
|
export function getReportExportUrl(id: string, projectId?: string): string {
|
||||||
return `${BASE}/reports/${encodeURIComponent(id)}/export.html${qp({ projectId })}`;
|
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 { getReportExportUrl } from "../api.js";
|
||||||
import { useReportPreview } from "../useReportPreview.js";
|
import { useReportPreview } from "../useReportPreview.js";
|
||||||
import type { ReportRecord } from "../types.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"];
|
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 }) {
|
export function ReportDetailPanel({ report, projectId }: { report?: ReportRecord; projectId?: string }) {
|
||||||
const frameRef = useRef<HTMLIFrameElement | null>(null);
|
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, []);
|
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">
|
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-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">{report.cadence} • {report.status} • {report.periodStart} → {report.periodEnd}</div>
|
<div className="reports-detail-meta">{currentReport.cadence} • {currentReport.status} • {currentReport.periodStart} → {currentReport.periodEnd}</div>
|
||||||
<div className="reports-detail-body">
|
<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>
|
<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}
|
{loading ? <div>Loading preview...</div> : null}
|
||||||
{error ? <div>{error}</div> : null}
|
{error ? <div>{error}</div> : null}
|
||||||
<iframe ref={frameRef} sandbox="allow-same-origin" srcDoc={html} title="Report preview" />
|
<iframe ref={frameRef} sandbox="allow-same-origin" srcDoc={html} title="Report preview" />
|
||||||
</div>
|
</div>
|
||||||
|
<ReportApprovalPanel report={currentReport} onReportChange={setCurrentReport} />
|
||||||
|
<ShareBlocksPanel report={currentReport} />
|
||||||
</div>;
|
</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 type { PluginContext } from "@fusion/core";
|
||||||
import { definePlugin } from "@fusion/plugin-sdk";
|
import { definePlugin } from "@fusion/plugin-sdk";
|
||||||
|
import { initializeApprovalState } from "./approval.js";
|
||||||
import { runReviewPanel } from "./review-panel.js";
|
import { runReviewPanel } from "./review-panel.js";
|
||||||
import { ensureReportSchema } from "./report-schema.js";
|
import { ensureReportSchema } from "./report-schema.js";
|
||||||
|
import { createReportApprovalRoutes } from "./routes/report-approval-routes.js";
|
||||||
import { createReportExportRoutes } from "./routes/report-export-routes.js";
|
import { createReportExportRoutes } from "./routes/report-export-routes.js";
|
||||||
import { createReportListRoutes } from "./routes/report-list-routes.js";
|
import { createReportListRoutes } from "./routes/report-list-routes.js";
|
||||||
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.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 type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
|
||||||
import { ReportStore } from "./store/report-store.js";
|
import { ReportStore } from "./store/report-store.js";
|
||||||
import { settingsSchema } from "./settings.js";
|
|
||||||
export { ReportsDashboardView } from "./dashboard-view.js";
|
export { ReportsDashboardView } from "./dashboard-view.js";
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
@@ -24,7 +32,7 @@ const plugin = definePlugin({
|
|||||||
hooks: {
|
hooks: {
|
||||||
onSchemaInit: ensureReportSchema,
|
onSchemaInit: ensureReportSchema,
|
||||||
},
|
},
|
||||||
routes: [...createReportListRoutes(), ...createReportExportRoutes()],
|
routes: [...createReportListRoutes(), ...createReportExportRoutes(), ...createReportApprovalRoutes()],
|
||||||
dashboardViews: [
|
dashboardViews: [
|
||||||
{
|
{
|
||||||
viewId: "reports",
|
viewId: "reports",
|
||||||
@@ -87,7 +95,26 @@ export async function runGeneratedReportReview(input: RunGeneratedReportReviewIn
|
|||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}, ctx);
|
}, 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;
|
return combinedReview;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ function createRecord(overrides: Partial<Report> = {}, metadata: Record<string,
|
|||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
|
approvalState: "not_required",
|
||||||
|
approvalHistory: [],
|
||||||
draftMarkdown: null,
|
draftMarkdown: null,
|
||||||
renderedHtmlPath: null,
|
renderedHtmlPath: null,
|
||||||
combinedReview: null,
|
combinedReview: null,
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ function createRecord(metadata: Record<string, unknown> = {}): Report {
|
|||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
|
approvalState: "not_required",
|
||||||
|
approvalHistory: [],
|
||||||
draftMarkdown: null,
|
draftMarkdown: null,
|
||||||
renderedHtmlPath: null,
|
renderedHtmlPath: null,
|
||||||
renderedHtml: null,
|
renderedHtml: null,
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { Database } from "@fusion/core";
|
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 {
|
export function ensureReportSchema(db: Database): void {
|
||||||
db.exec(`
|
db.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS reports (
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
@@ -18,6 +25,8 @@ export function ensureReportSchema(db: Database): void {
|
|||||||
publishedAt TEXT,
|
publishedAt TEXT,
|
||||||
archivedAt TEXT,
|
archivedAt TEXT,
|
||||||
failureReason TEXT,
|
failureReason TEXT,
|
||||||
|
approval_state TEXT NOT NULL DEFAULT 'not_required',
|
||||||
|
approval_history TEXT NOT NULL DEFAULT '[]',
|
||||||
draftMarkdown TEXT,
|
draftMarkdown TEXT,
|
||||||
renderedHtmlPath TEXT,
|
renderedHtmlPath TEXT,
|
||||||
rendered_html TEXT,
|
rendered_html TEXT,
|
||||||
@@ -38,12 +47,23 @@ export function ensureReportSchema(db: Database): void {
|
|||||||
ON reports(periodStart, periodEnd, id);
|
ON reports(periodStart, periodEnd, id);
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const columns = db.prepare("PRAGMA table_info(reports)").all() as Array<{ name: string }>;
|
addColumnIfMissing(db, "reports", "rendered_html", "TEXT");
|
||||||
const names = new Set(columns.map((column) => column.name));
|
addColumnIfMissing(db, "reports", "rendered_html_generated_at", "TEXT");
|
||||||
if (!names.has("rendered_html")) {
|
addColumnIfMissing(db, "reports", "approval_state", "TEXT NOT NULL DEFAULT 'not_required'");
|
||||||
db.exec("ALTER TABLE reports ADD COLUMN rendered_html TEXT");
|
addColumnIfMissing(db, "reports", "approval_history", "TEXT NOT NULL DEFAULT '[]'");
|
||||||
}
|
|
||||||
if (!names.has("rendered_html_generated_at")) {
|
db.exec(`
|
||||||
db.exec("ALTER TABLE reports ADD COLUMN rendered_html_generated_at TEXT");
|
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,
|
publishedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
|
approvalState: "not_required",
|
||||||
|
approvalHistory: [],
|
||||||
draftMarkdown: null,
|
draftMarkdown: null,
|
||||||
renderedHtmlPath: null,
|
renderedHtmlPath: null,
|
||||||
renderedHtml: 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 { randomUUID } from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import type { Database } from "@fusion/core";
|
import type { Database } from "@fusion/core";
|
||||||
|
import type { ApprovalDecision, ApprovalState } from "../approval.js";
|
||||||
import type { CombinedReview } from "../review-types.js";
|
import type { CombinedReview } from "../review-types.js";
|
||||||
import {
|
import {
|
||||||
type Report,
|
type Report,
|
||||||
@@ -27,6 +28,8 @@ interface ReportRow {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
failureReason: string | null;
|
failureReason: string | null;
|
||||||
|
approval_state: ApprovalState;
|
||||||
|
approval_history: string;
|
||||||
draftMarkdown: string | null;
|
draftMarkdown: string | null;
|
||||||
renderedHtmlPath: string | null;
|
renderedHtmlPath: string | null;
|
||||||
rendered_html: string | null;
|
rendered_html: string | null;
|
||||||
@@ -76,6 +79,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
publishedAt: null,
|
publishedAt: null,
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
failureReason: null,
|
failureReason: null,
|
||||||
|
approvalState: "not_required",
|
||||||
|
approvalHistory: [],
|
||||||
draftMarkdown: input.draftMarkdown ?? null,
|
draftMarkdown: input.draftMarkdown ?? null,
|
||||||
renderedHtmlPath: null,
|
renderedHtmlPath: null,
|
||||||
renderedHtml: null,
|
renderedHtml: null,
|
||||||
@@ -92,11 +97,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
id, cadence, periodStart, periodEnd, title, status,
|
id, cadence, periodStart, periodEnd, title, status,
|
||||||
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
|
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
|
||||||
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
|
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
|
||||||
|
approval_state, approval_history,
|
||||||
draftMarkdown, renderedHtmlPath, rendered_html, rendered_html_generated_at, metadataJson, combinedReviewJson, createdAt, updatedAt
|
draftMarkdown, renderedHtmlPath, rendered_html, rendered_html_generated_at, metadataJson, combinedReviewJson, createdAt, updatedAt
|
||||||
) VALUES (
|
) VALUES (
|
||||||
@id, @cadence, @periodStart, @periodEnd, @title, @status,
|
@id, @cadence, @periodStart, @periodEnd, @title, @status,
|
||||||
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
|
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
|
||||||
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
|
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
|
||||||
|
@approvalState, @approvalHistory,
|
||||||
@draftMarkdown, @renderedHtmlPath, @renderedHtml, @renderedHtmlGeneratedAt, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
|
@draftMarkdown, @renderedHtmlPath, @renderedHtml, @renderedHtmlGeneratedAt, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
|
||||||
)
|
)
|
||||||
`).run(this.toDbParams(report, true));
|
`).run(this.toDbParams(report, true));
|
||||||
@@ -164,6 +171,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
renderedHtml: patch.renderedHtml ?? current.renderedHtml,
|
renderedHtml: patch.renderedHtml ?? current.renderedHtml,
|
||||||
renderedHtmlGeneratedAt: patch.renderedHtmlGeneratedAt ?? current.renderedHtmlGeneratedAt,
|
renderedHtmlGeneratedAt: patch.renderedHtmlGeneratedAt ?? current.renderedHtmlGeneratedAt,
|
||||||
failureReason: patch.failureReason ?? current.failureReason,
|
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(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -269,6 +283,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
publishedAt: row.publishedAt,
|
publishedAt: row.publishedAt,
|
||||||
archivedAt: row.archivedAt,
|
archivedAt: row.archivedAt,
|
||||||
failureReason: row.failureReason,
|
failureReason: row.failureReason,
|
||||||
|
approvalState: row.approval_state,
|
||||||
|
approvalHistory: this.parseApprovalHistory(row.approval_history),
|
||||||
draftMarkdown: row.draftMarkdown,
|
draftMarkdown: row.draftMarkdown,
|
||||||
renderedHtmlPath: row.renderedHtmlPath,
|
renderedHtmlPath: row.renderedHtmlPath,
|
||||||
renderedHtml: row.rendered_html,
|
renderedHtml: row.rendered_html,
|
||||||
@@ -297,6 +313,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
publishedAt = @publishedAt,
|
publishedAt = @publishedAt,
|
||||||
archivedAt = @archivedAt,
|
archivedAt = @archivedAt,
|
||||||
failureReason = @failureReason,
|
failureReason = @failureReason,
|
||||||
|
approval_state = @approvalState,
|
||||||
|
approval_history = @approvalHistory,
|
||||||
draftMarkdown = @draftMarkdown,
|
draftMarkdown = @draftMarkdown,
|
||||||
renderedHtmlPath = @renderedHtmlPath,
|
renderedHtmlPath = @renderedHtmlPath,
|
||||||
rendered_html = @renderedHtml,
|
rendered_html = @renderedHtml,
|
||||||
@@ -329,6 +347,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
publishedAt: report.publishedAt,
|
publishedAt: report.publishedAt,
|
||||||
archivedAt: report.archivedAt,
|
archivedAt: report.archivedAt,
|
||||||
failureReason: report.failureReason,
|
failureReason: report.failureReason,
|
||||||
|
approvalState: report.approvalState,
|
||||||
|
approvalHistory: JSON.stringify(report.approvalHistory ?? []),
|
||||||
draftMarkdown: report.draftMarkdown,
|
draftMarkdown: report.draftMarkdown,
|
||||||
renderedHtmlPath: report.renderedHtmlPath,
|
renderedHtmlPath: report.renderedHtmlPath,
|
||||||
renderedHtml: report.renderedHtml,
|
renderedHtml: report.renderedHtml,
|
||||||
@@ -357,4 +377,14 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
|||||||
return null;
|
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";
|
import type { CombinedReview } from "../review-types.js";
|
||||||
|
|
||||||
export type ReportCadence = "daily" | "weekly" | "monthly" | "quarterly" | "manual";
|
export type ReportCadence = "daily" | "weekly" | "monthly" | "quarterly" | "manual";
|
||||||
@@ -28,6 +29,8 @@ export interface Report {
|
|||||||
publishedAt: string | null;
|
publishedAt: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
failureReason: string | null;
|
failureReason: string | null;
|
||||||
|
approvalState: ApprovalState;
|
||||||
|
approvalHistory: ApprovalDecision[];
|
||||||
draftMarkdown: string | null;
|
draftMarkdown: string | null;
|
||||||
renderedHtmlPath: string | null;
|
renderedHtmlPath: string | null;
|
||||||
renderedHtml: string | null;
|
renderedHtml: string | null;
|
||||||
@@ -47,7 +50,7 @@ export interface ReportCreateInput {
|
|||||||
draftMarkdown?: string;
|
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 {
|
export interface ReportListFilter {
|
||||||
cadence?: ReportCadence;
|
cadence?: ReportCadence;
|
||||||
|
|||||||
Reference in New Issue
Block a user