feat(FN-3785): add standalone HTML rendering and export routes for reports
Adds a complete HTML rendering and export pipeline to the fusion-plugin-reports plugin, including a standalone HTML renderer with template and stylesheet support, new export routes (`/api/reports/:id/export/html`) that persist rendered HTML to the store, and plugin-type support for non-JSON route re Fusion-Task-Id: FN-3785
This commit is contained in:
81
plugins/fusion-plugin-reports/docs/rendering-contract.md
Normal file
81
plugins/fusion-plugin-reports/docs/rendering-contract.md
Normal file
@@ -0,0 +1,81 @@
|
||||
## ReportRecord input shape
|
||||
|
||||
```ts
|
||||
interface ReportRecord {
|
||||
id: string;
|
||||
title: string;
|
||||
cadence: "daily" | "weekly" | "monthly" | "quarterly" | "manual";
|
||||
period: { start: string; end: string };
|
||||
generatedAt: string;
|
||||
status: string;
|
||||
settings: {
|
||||
sections?: string[];
|
||||
sectionOrder?: string[];
|
||||
branding?: {
|
||||
accentColor?: string;
|
||||
logoDataUri?: string;
|
||||
};
|
||||
};
|
||||
sections: {
|
||||
summary?: string;
|
||||
system?: {
|
||||
wins?: string[];
|
||||
highlights?: string[];
|
||||
lowlights?: string[];
|
||||
proposals?: string[];
|
||||
deepDives?: string[];
|
||||
};
|
||||
perAgent?: Array<{
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
wins?: string[];
|
||||
highlights?: string[];
|
||||
lowlights?: string[];
|
||||
proposals?: string[];
|
||||
deepDives?: string[];
|
||||
}>;
|
||||
dataCoverage?: string[];
|
||||
};
|
||||
reviewPanel?: {
|
||||
overallVerdict: string;
|
||||
consensusSummary: string;
|
||||
individual: Array<{ memberName: string; verdict: string }>;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Section identifiers
|
||||
|
||||
- `data-section="summary"`
|
||||
- `data-section="system-wins"`
|
||||
- `data-section="system-highlights"`
|
||||
- `data-section="system-lowlights"`
|
||||
- `data-section="system-proposals"`
|
||||
- `data-section="system-deep-dives"`
|
||||
- `data-section="agent-card"`
|
||||
- `data-section="data-coverage"`
|
||||
- `data-section="review-panel"`
|
||||
|
||||
## Theme tokens
|
||||
|
||||
- `--space-xs`, `--space-sm`, `--space-md`, `--space-lg`, `--space-xl`
|
||||
- `--radius-sm`, `--radius-md`, `--radius-lg`
|
||||
- `--bg`, `--surface`, `--card`, `--text`, `--text-muted`, `--border`
|
||||
- `--triage`, `--todo`, `--in-progress`, `--in-review`, `--done`
|
||||
- `--color-success`, `--color-error`, `--color-warning`, `--color-info`
|
||||
- `--report-accent`
|
||||
|
||||
## HTTP endpoints
|
||||
|
||||
- `GET /api/plugins/reports/reports/:id/export.html`
|
||||
- `200 text/html; charset=utf-8` + `Content-Disposition: attachment; filename="<slug>.html"`
|
||||
- `404` when report ID does not exist
|
||||
- `409` when report is not yet generated
|
||||
- `GET /api/plugins/reports/reports/:id/preview.html`
|
||||
- `200 text/html; charset=utf-8` body-only fragment (`<article>...</article>`)
|
||||
- `404` when report ID does not exist
|
||||
- `409` when report is not yet generated
|
||||
|
||||
## Stability
|
||||
|
||||
This rendering contract is shared with downstream dashboard/share integrations (FN-3786 / FN-3787). Any breaking change to markers, token names, or endpoint contract requires coordinated updates across those tasks.
|
||||
@@ -8,6 +8,10 @@
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./render": {
|
||||
"types": "./src/render/index.ts",
|
||||
"import": "./src/render/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PluginContext } from "@fusion/core";
|
||||
import { definePlugin } from "@fusion/plugin-sdk";
|
||||
import { runReviewPanel } from "./review-panel.js";
|
||||
import { ensureReportSchema } from "./report-schema.js";
|
||||
import { createReportExportRoutes } from "./routes/report-export-routes.js";
|
||||
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.js";
|
||||
import type { ReportCadence, ReportCreateInput } from "./store/report-types.js";
|
||||
import { ReportStore } from "./store/report-store.js";
|
||||
@@ -21,6 +22,7 @@ const plugin = definePlugin({
|
||||
hooks: {
|
||||
onSchemaInit: ensureReportSchema,
|
||||
},
|
||||
routes: createReportExportRoutes(),
|
||||
});
|
||||
|
||||
export interface RunGeneratedReportReviewInput {
|
||||
@@ -85,3 +87,4 @@ export * from "./review-panel.js";
|
||||
export { ensureReportSchema } from "./report-schema.js";
|
||||
export { ReportStore, ReportStoreError, type ReportStoreEvents } from "./store/report-store.js";
|
||||
export * from "./store/report-types.js";
|
||||
export * from "./render/index.js";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { escapeAttr, escapeHtml } from "../escape.js";
|
||||
|
||||
describe("escape", () => {
|
||||
it("escapes html special characters", () => {
|
||||
expect(escapeHtml("&<>'\"")).toBe("&<>'"");
|
||||
});
|
||||
|
||||
it("escapes attribute-sensitive characters", () => {
|
||||
expect(escapeAttr("a`b&c")).toBe("a`b&c");
|
||||
});
|
||||
|
||||
it("passes unicode through", () => {
|
||||
expect(escapeHtml("こんにちは 🌍")).toBe("こんにちは 🌍");
|
||||
});
|
||||
|
||||
it("passes unicode through in attributes", () => {
|
||||
expect(escapeAttr("こんにちは 🌍")).toBe("こんにちは 🌍");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Report } from "../../store/report-types.js";
|
||||
import { renderReportHtml } from "../html-template.js";
|
||||
|
||||
function createRecord(overrides: Partial<Report> = {}, metadata: Record<string, unknown> = {}): Report {
|
||||
const base: Report = {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-02",
|
||||
title: "Weekly report",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-02T00:00:00.000Z",
|
||||
generationCompletedAt: "2026-05-02T00:01:00.000Z",
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-02T00:00:00.000Z",
|
||||
updatedAt: "2026-05-02T00:01:00.000Z",
|
||||
metadata,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
renderedHtml: overrides.renderedHtml ?? base.renderedHtml,
|
||||
renderedHtmlGeneratedAt: overrides.renderedHtmlGeneratedAt ?? base.renderedHtmlGeneratedAt,
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderReportHtml", () => {
|
||||
it("renders shell for empty record", () => {
|
||||
const html = renderReportHtml(createRecord());
|
||||
expect(html).toContain("<!doctype html>");
|
||||
expect(html).toContain("data-section=\"data-coverage\"");
|
||||
});
|
||||
|
||||
it("renders mixed sections with markers", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
sections: {
|
||||
summary: "hello",
|
||||
system: { wins: ["w1"], highlights: ["h1"], lowlights: ["l1"], proposals: ["p1"], deepDives: ["d1"] },
|
||||
perAgent: [{ agentId: "a1", wins: ["x"] }],
|
||||
},
|
||||
}));
|
||||
expect(html).toContain('data-section="summary"');
|
||||
expect(html).toContain('data-section="system-wins"');
|
||||
expect(html).toContain('data-section="system-highlights"');
|
||||
expect(html).toContain('data-section="system-lowlights"');
|
||||
expect(html).toContain('data-section="system-proposals"');
|
||||
expect(html).toContain('data-section="system-deep-dives"');
|
||||
expect(html).toContain('data-section="agent-card"');
|
||||
});
|
||||
|
||||
it("omits toggled off sections", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
settings: { enabledSections: ["wins"] },
|
||||
sections: { system: { wins: ["w1"], highlights: ["h1"] } },
|
||||
}));
|
||||
expect(html).toContain('data-section="system-wins"');
|
||||
expect(html).not.toContain('data-section="system-highlights"');
|
||||
});
|
||||
|
||||
it("respects section order", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
settings: { sectionOrder: ["proposals", "wins"], enabledSections: ["wins", "proposals"] },
|
||||
sections: { system: { wins: ["w1"], proposals: ["p1"] } },
|
||||
}));
|
||||
expect(html.indexOf('data-section="system-proposals"')).toBeLessThan(html.indexOf('data-section="system-wins"'));
|
||||
});
|
||||
|
||||
it("renders per-agent cards with stable ids", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
sections: { perAgent: [{ agentId: "agent-1" }, { agentId: "agent-2" }] },
|
||||
}));
|
||||
expect(html).toContain('data-agent-id="agent-1"');
|
||||
expect(html).toContain('data-agent-id="agent-2"');
|
||||
});
|
||||
|
||||
it("escapes hostile inputs", () => {
|
||||
const html = renderReportHtml(createRecord({}, {
|
||||
sections: { summary: '<script>alert(1)</script> " onmouseover=' },
|
||||
}));
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
expect(html).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("applies explicit theme", () => {
|
||||
const dark = renderReportHtml(createRecord(), { theme: "dark" });
|
||||
const light = renderReportHtml(createRecord(), { theme: "light" });
|
||||
expect(dark).toContain('data-theme="dark"');
|
||||
expect(light).toContain('data-theme="light"');
|
||||
});
|
||||
|
||||
it("returns body-only when includeChrome false", () => {
|
||||
const body = renderReportHtml(createRecord(), { includeChrome: false });
|
||||
expect(body.startsWith("<!doctype html>")).toBe(false);
|
||||
expect(body.startsWith("<article")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Report } from "../../store/report-types.js";
|
||||
import { renderStandaloneReportHtml, slugifyReportFilename } from "../standalone-html.js";
|
||||
|
||||
function createRecord(metadata: Record<string, unknown> = {}): Report {
|
||||
return {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-02",
|
||||
title: "Demo",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-02T00:00:00.000Z",
|
||||
generationCompletedAt: "2026-05-02T00:01:00.000Z",
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata,
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-02T00:00:00.000Z",
|
||||
updatedAt: "2026-05-02T00:01:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("renderStandaloneReportHtml", () => {
|
||||
it("renders one full html document with one style block", () => {
|
||||
const html = renderStandaloneReportHtml(createRecord());
|
||||
expect(html.startsWith("<!doctype html>")).toBe(true);
|
||||
expect((html.match(/<style>/g) ?? []).length).toBe(1);
|
||||
expect(html).toContain("--space-xs");
|
||||
});
|
||||
|
||||
it("contains no external links except allowlisted ones", () => {
|
||||
const html = renderStandaloneReportHtml(createRecord());
|
||||
const matches = [...html.matchAll(/(href|src)=\"https?:[^\"]+/gi)];
|
||||
expect(matches.length).toBe(0);
|
||||
});
|
||||
|
||||
it("strips external image sources", () => {
|
||||
const html = renderStandaloneReportHtml(createRecord({
|
||||
settings: { branding: { logoDataUri: "http://example.com/logo.png" } },
|
||||
}));
|
||||
expect(html).not.toContain("http://example.com/logo.png");
|
||||
});
|
||||
|
||||
it("is deterministic for same input", () => {
|
||||
const a = renderStandaloneReportHtml(createRecord());
|
||||
const b = renderStandaloneReportHtml(createRecord());
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("slugifies report filenames", () => {
|
||||
const slug = slugifyReportFilename({ title: "My Weekly Report", periodStart: "2026-05-01", periodEnd: "2026-05-02" });
|
||||
expect(slug).toBe("my-weekly-report-2026-05-01-2026-05-02.html");
|
||||
});
|
||||
});
|
||||
12
plugins/fusion-plugin-reports/src/render/escape.ts
Normal file
12
plugins/fusion-plugin-reports/src/render/escape.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function escapeHtml(input: string): string {
|
||||
return input
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
export function escapeAttr(input: string): string {
|
||||
return escapeHtml(input).replaceAll("`", "`");
|
||||
}
|
||||
40
plugins/fusion-plugin-reports/src/render/html-styles.ts
Normal file
40
plugins/fusion-plugin-reports/src/render/html-styles.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { escapeAttr } from "./escape.js";
|
||||
|
||||
export interface ReportBranding {
|
||||
accentColor?: string;
|
||||
logoDataUri?: string;
|
||||
logoTextColor?: string;
|
||||
}
|
||||
|
||||
export const REPORT_STYLESHEET = `
|
||||
:root {
|
||||
--space-xs: 4px; --space-sm: 8px; --space-md: 12px; --space-lg: 16px; --space-xl: 24px;
|
||||
--radius-sm: 4px; --radius-md: 8px; --radius-lg: 12px;
|
||||
--bg: #0d1117; --surface: #161b22; --card: #1f2733; --text: #e6edf3; --text-muted: #8b949e; --border: #30363d;
|
||||
--triage: #8b949e; --todo: #58a6ff; --in-progress: #d29922; --in-review: #a371f7; --done: #3fb950;
|
||||
--color-success: #3fb950; --color-error: #f85149; --color-warning: #d29922; --color-info: #58a6ff;
|
||||
--report-accent: #5b8def;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff; --surface: #f6f8fa; --card: #ffffff; --text: #1f2328; --text-muted: #59636e; --border: #d0d7de;
|
||||
}
|
||||
html, body { margin: 0; padding: 0; background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
|
||||
.report { max-width: 980px; margin: 0 auto; padding: var(--space-xl); }
|
||||
.report-header, .report-section, .agent-card, .report-footer { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: var(--space-lg); margin-bottom: var(--space-lg); }
|
||||
.report-title { margin: 0 0 var(--space-sm); font-size: 28px; }
|
||||
.report-meta { display: flex; flex-wrap: wrap; gap: var(--space-sm); color: var(--text-muted); }
|
||||
.pill { border-radius: 999px; padding: 2px 10px; border: 1px solid var(--border); }
|
||||
.status { background: color-mix(in srgb, var(--report-accent) 22%, transparent); color: var(--report-accent); }
|
||||
.section-title { margin: 0 0 var(--space-sm); font-size: 18px; }
|
||||
.section-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-md); }
|
||||
.panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius-md); padding: var(--space-md); }
|
||||
ul { margin: var(--space-sm) 0 0; padding-left: 18px; }
|
||||
`;
|
||||
|
||||
export function buildBrandingCss(branding: ReportBranding | undefined): string {
|
||||
if (!branding) return "";
|
||||
const accent = branding.accentColor ? `--report-accent: ${escapeAttr(branding.accentColor)};` : "";
|
||||
const logo = branding.logoTextColor ? `--report-logo-text: ${escapeAttr(branding.logoTextColor)};` : "";
|
||||
if (!accent && !logo) return "";
|
||||
return `:root { ${accent} ${logo} }`;
|
||||
}
|
||||
137
plugins/fusion-plugin-reports/src/render/html-template.ts
Normal file
137
plugins/fusion-plugin-reports/src/render/html-template.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Rendering approach: all agent/user text is escaped as plain text.
|
||||
* No markdown/HTML pass-through is allowed in this renderer.
|
||||
*/
|
||||
import type { CombinedReview } from "../review-types.js";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
import { escapeAttr, escapeHtml } from "./escape.js";
|
||||
import { buildBrandingCss, REPORT_STYLESHEET, type ReportBranding } from "./html-styles.js";
|
||||
|
||||
export interface ReportRecord extends Report {
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ReportRenderOptions {
|
||||
theme?: "dark" | "light" | "auto";
|
||||
includeChrome?: boolean;
|
||||
}
|
||||
|
||||
interface ReportSectionsPayload {
|
||||
summary?: string;
|
||||
system?: SectionBuckets;
|
||||
perAgent?: Array<AgentSection>;
|
||||
dataCoverage?: string[];
|
||||
}
|
||||
|
||||
interface SectionBuckets {
|
||||
wins?: string[];
|
||||
highlights?: string[];
|
||||
lowlights?: string[];
|
||||
proposals?: string[];
|
||||
deepDives?: string[];
|
||||
}
|
||||
|
||||
interface AgentSection extends SectionBuckets {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
function asObj(v: unknown): Record<string, unknown> {
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function asString(v: unknown): string | undefined {
|
||||
return typeof v === "string" && v.trim() ? v : undefined;
|
||||
}
|
||||
|
||||
function asStringArray(v: unknown): string[] {
|
||||
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string").map((x) => x.trim()).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function extract(record: ReportRecord): { sections: ReportSectionsPayload; order: string[]; enabled: Set<string>; branding: ReportBranding } {
|
||||
const metadata = asObj(record.metadata);
|
||||
const sections = asObj(metadata.sections);
|
||||
const settings = asObj(metadata.settings);
|
||||
const branding = asObj(settings.branding);
|
||||
const order = asStringArray(settings.sectionOrder);
|
||||
const enabled = new Set(asStringArray(settings.enabledSections));
|
||||
return {
|
||||
sections: {
|
||||
summary: asString(sections.summary),
|
||||
system: asObj(sections.system) as SectionBuckets,
|
||||
perAgent: Array.isArray(sections.perAgent)
|
||||
? sections.perAgent.map((item) => {
|
||||
const agent = asObj(item);
|
||||
return {
|
||||
agentId: asString(agent.agentId) ?? "unknown",
|
||||
agentName: asString(agent.agentName),
|
||||
wins: asStringArray(agent.wins),
|
||||
highlights: asStringArray(agent.highlights),
|
||||
lowlights: asStringArray(agent.lowlights),
|
||||
proposals: asStringArray(agent.proposals),
|
||||
deepDives: asStringArray(agent.deepDives),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
dataCoverage: asStringArray(sections.dataCoverage),
|
||||
},
|
||||
order,
|
||||
enabled,
|
||||
branding: {
|
||||
accentColor: asString(branding.accentColor),
|
||||
logoDataUri: asString(branding.logoDataUri),
|
||||
logoTextColor: asString(branding.logoTextColor),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function listSection(title: string, items: string[] | undefined, marker: string): string {
|
||||
if (!items || items.length === 0) return "";
|
||||
return `<section class="panel" data-section="${marker}"><h3>${escapeHtml(title)}</h3><ul>${items.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></section>`;
|
||||
}
|
||||
|
||||
function cadenceLabel(cadence: string): string {
|
||||
return cadence.charAt(0).toUpperCase() + cadence.slice(1);
|
||||
}
|
||||
|
||||
export function renderReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
|
||||
const { sections, order, enabled, branding } = extract(record);
|
||||
const dataTheme = options.theme && options.theme !== "auto" ? options.theme : "dark";
|
||||
const includeChrome = options.includeChrome !== false;
|
||||
const title = asString(record.title) ?? "Fusion Activity Report";
|
||||
const summary = sections.summary ? `<section class="report-section" data-section="summary"><h2 class="section-title">Executive Summary</h2><p>${escapeHtml(sections.summary)}</p></section>` : "";
|
||||
const system = sections.system ?? {};
|
||||
const systemMap: Record<string, string> = {
|
||||
wins: listSection("Wins", system.wins, "system-wins"),
|
||||
highlights: listSection("Highlights", system.highlights, "system-highlights"),
|
||||
lowlights: listSection("Lowlights", system.lowlights, "system-lowlights"),
|
||||
proposals: listSection("Proposals", system.proposals, "system-proposals"),
|
||||
"deep-dives": listSection("Deep dives", system.deepDives, "system-deep-dives"),
|
||||
};
|
||||
const orderedKeys = order.length > 0 ? [...order, ...Object.keys(systemMap).filter((k) => !order.includes(k))] : Object.keys(systemMap);
|
||||
const systemSections = orderedKeys
|
||||
.filter((key) => key in systemMap)
|
||||
.filter((key) => enabled.size === 0 || enabled.has(key))
|
||||
.map((key) => systemMap[key])
|
||||
.join("");
|
||||
|
||||
const perAgent = sections.perAgent ?? [];
|
||||
const perAgentHtml = (enabled.size === 0 || enabled.has("per-agent"))
|
||||
? `<section class="report-section" data-section="agent-card"><h2 class="section-title">Per-agent sections</h2>${perAgent.map((agent) => `<article class="agent-card" data-agent-id="${escapeAttr(agent.agentId)}"><h3>${escapeHtml(agent.agentName ?? agent.agentId)}</h3><div class="section-grid">${listSection("Wins", agent.wins, "agent-wins")}${listSection("Highlights", agent.highlights, "agent-highlights")}${listSection("Lowlights", agent.lowlights, "agent-lowlights")}${listSection("Proposals", agent.proposals, "agent-proposals")}${listSection("Deep dives", agent.deepDives, "agent-deep-dives")}</div></article>`).join("")}</section>`
|
||||
: "";
|
||||
|
||||
const coverage = sections.dataCoverage ?? ["Task board", "Agent activity", "Missions", "Run audit", "Workflow/test/build", "Manual notes"];
|
||||
const coverageSection = `<section class="report-footer" data-section="data-coverage"><h2 class="section-title">Data sources & coverage</h2><ul>${coverage.map((item) => `<li>${escapeHtml(item)}</li>`).join("")}</ul></section>`;
|
||||
|
||||
const review = (record.combinedReview as CombinedReview | null) ?? null;
|
||||
const reviewSection = review
|
||||
? `<section class="report-footer" data-section="review-panel"><h2 class="section-title">Review panel summary</h2><p>${escapeHtml(review.overallVerdict)} — ${escapeHtml(review.consensusSummary)}</p><ul>${review.individual.map((member) => `<li>${escapeHtml(member.memberName)}: ${escapeHtml(member.verdict)}</li>`).join("")}</ul></section>`
|
||||
: "";
|
||||
|
||||
const header = `<header class="report-header"><h1 class="report-title">${escapeHtml(title)}</h1><div class="report-meta"><span class="pill">${escapeHtml(cadenceLabel(record.cadence))}</span><span class="pill">${escapeHtml(record.periodStart)} → ${escapeHtml(record.periodEnd)}</span><span class="pill">Generated ${escapeHtml(record.generationCompletedAt ?? record.updatedAt)}</span><span class="pill status">${escapeHtml(record.status)}</span></div>${branding.logoDataUri ? `<p><img src="${escapeAttr(branding.logoDataUri)}" alt="Logo" style="max-height:36px"/></p>` : ""}</header>`;
|
||||
|
||||
const article = `<article class="report">${header}${summary}${systemSections ? `<section class="report-section"><h2 class="section-title">System-wide rollup</h2><div class="section-grid">${systemSections}</div></section>` : ""}${perAgentHtml}${coverageSection}${reviewSection}</article>`;
|
||||
|
||||
if (!includeChrome) return article;
|
||||
return `<!doctype html><html lang="en" data-theme="${escapeAttr(dataTheme)}"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>${escapeHtml(title)}</title><style>${REPORT_STYLESHEET}\n${buildBrandingCss(branding)}</style></head><body>${article}</body></html>`;
|
||||
}
|
||||
4
plugins/fusion-plugin-reports/src/render/index.ts
Normal file
4
plugins/fusion-plugin-reports/src/render/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "./escape.js";
|
||||
export * from "./html-styles.js";
|
||||
export * from "./html-template.js";
|
||||
export * from "./standalone-html.js";
|
||||
75
plugins/fusion-plugin-reports/src/render/standalone-html.ts
Normal file
75
plugins/fusion-plugin-reports/src/render/standalone-html.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { extname } from "node:path";
|
||||
import type { Report } from "../store/report-types.js";
|
||||
import { escapeAttr } from "./escape.js";
|
||||
import { buildBrandingCss, REPORT_STYLESHEET } from "./html-styles.js";
|
||||
import { renderReportHtml, type ReportRecord, type ReportRenderOptions } from "./html-template.js";
|
||||
|
||||
const ALLOWLISTED_LINK_PREFIXES = ["https://runfusion.ai"];
|
||||
|
||||
function inferMime(path: string): string {
|
||||
const ext = extname(path).toLowerCase();
|
||||
if (ext === ".png") return "image/png";
|
||||
if (ext === ".jpg" || ext === ".jpeg") return "image/jpeg";
|
||||
if (ext === ".gif") return "image/gif";
|
||||
if (ext === ".webp") return "image/webp";
|
||||
if (ext === ".svg") return "image/svg+xml";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function toDataUri(path: string): string {
|
||||
const mime = inferMime(path);
|
||||
const bytes = readFileSync(path);
|
||||
return `data:${mime};base64,${bytes.toString("base64")}`;
|
||||
}
|
||||
|
||||
function removeScripts(html: string): string {
|
||||
return html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "");
|
||||
}
|
||||
|
||||
function sanitizeExternalImages(html: string): string {
|
||||
return html.replace(/<img\b([^>]*?)\ssrc=["'](https?:[^"']+)["']([^>]*)>/gi, "<!-- stripped external image -->");
|
||||
}
|
||||
|
||||
function assertNoExternalRefs(html: string): string {
|
||||
const matches = [...html.matchAll(/(href|src)\s*=\s*["'](https?:[^"']+)["']/gi)];
|
||||
const disallowed = matches.filter((m) => !ALLOWLISTED_LINK_PREFIXES.some((prefix) => m[2]?.startsWith(prefix)));
|
||||
if (disallowed.length === 0) return html;
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
throw new Error(`Standalone HTML contains external refs: ${disallowed.map((m) => m[2]).join(", ")}`);
|
||||
}
|
||||
return `${html}\n<!-- WARNING: stripped/retained external refs detected -->`;
|
||||
}
|
||||
|
||||
function resolveBrandLogo(record: ReportRecord): string | undefined {
|
||||
const metadata = record.metadata && typeof record.metadata === "object" ? record.metadata as Record<string, unknown> : {};
|
||||
const settings = metadata.settings && typeof metadata.settings === "object" ? metadata.settings as Record<string, unknown> : {};
|
||||
const branding = settings.branding && typeof settings.branding === "object" ? settings.branding as Record<string, unknown> : {};
|
||||
const logoDataUri = typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined;
|
||||
const logoPath = typeof branding.logoPath === "string" ? branding.logoPath : undefined;
|
||||
if (logoDataUri?.startsWith("data:")) return logoDataUri;
|
||||
if (logoPath && !/^https?:/i.test(logoPath)) return toDataUri(logoPath);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function slugifyReportFilename(record: Pick<Report, "title" | "periodStart" | "periodEnd">): string {
|
||||
const base = `${record.title}-${record.periodStart}-${record.periodEnd}`.toLowerCase();
|
||||
const slug = base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 120);
|
||||
return `${slug || "fusion-report"}.html`;
|
||||
}
|
||||
|
||||
export function renderStandaloneReportHtml(record: ReportRecord, options: ReportRenderOptions = {}): string {
|
||||
const logoDataUri = resolveBrandLogo(record);
|
||||
const metadata = record.metadata && typeof record.metadata === "object" ? { ...(record.metadata as Record<string, unknown>) } : {};
|
||||
const settings = metadata.settings && typeof metadata.settings === "object" ? { ...(metadata.settings as Record<string, unknown>) } : {};
|
||||
const branding = settings.branding && typeof settings.branding === "object" ? { ...(settings.branding as Record<string, unknown>) } : {};
|
||||
if (logoDataUri) branding.logoDataUri = logoDataUri;
|
||||
settings.branding = branding;
|
||||
metadata.settings = settings;
|
||||
|
||||
const html = renderReportHtml({ ...record, metadata }, { ...options, includeChrome: true });
|
||||
const styleBlock = `<style>${REPORT_STYLESHEET}\n${buildBrandingCss({ accentColor: typeof branding.accentColor === "string" ? branding.accentColor : undefined, logoDataUri: typeof branding.logoDataUri === "string" ? branding.logoDataUri : undefined, logoTextColor: typeof branding.logoTextColor === "string" ? branding.logoTextColor : undefined })}</style>`;
|
||||
const withSingleStyle = html.replace(/<style>[\s\S]*?<\/style>/i, styleBlock);
|
||||
const sanitized = sanitizeExternalImages(removeScripts(withSingleStyle));
|
||||
return assertNoExternalRefs(sanitized);
|
||||
}
|
||||
@@ -20,6 +20,8 @@ export function ensureReportSchema(db: Database): void {
|
||||
failureReason TEXT,
|
||||
draftMarkdown TEXT,
|
||||
renderedHtmlPath TEXT,
|
||||
rendered_html TEXT,
|
||||
rendered_html_generated_at TEXT,
|
||||
metadataJson TEXT NOT NULL DEFAULT '{}',
|
||||
combinedReviewJson TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
@@ -35,4 +37,13 @@ export function ensureReportSchema(db: Database): void {
|
||||
CREATE INDEX IF NOT EXISTS idxReportsPeriod
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { PluginContext } from "@fusion/core";
|
||||
import type { Report } from "../../store/report-types.js";
|
||||
import { createReportExportRoutes } from "../report-export-routes.js";
|
||||
|
||||
function report(overrides: Partial<Report> = {}): Report {
|
||||
return {
|
||||
id: "rep_1",
|
||||
cadence: "daily",
|
||||
periodStart: "2026-05-01",
|
||||
periodEnd: "2026-05-02",
|
||||
title: "Demo",
|
||||
status: "review_complete",
|
||||
generationStartedAt: "2026-05-02T00:00:00.000Z",
|
||||
generationCompletedAt: "2026-05-02T00:01:00.000Z",
|
||||
reviewStartedAt: null,
|
||||
reviewCompletedAt: null,
|
||||
approvedAt: null,
|
||||
approvedBy: null,
|
||||
publishedAt: null,
|
||||
archivedAt: null,
|
||||
failureReason: null,
|
||||
draftMarkdown: null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata: {},
|
||||
combinedReview: null,
|
||||
createdAt: "2026-05-02T00:00:00.000Z",
|
||||
updatedAt: "2026-05-02T00:01:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function ctxWithStore(store: { getReport: (id: string) => Report | null; setRenderedHtml: (id: string, html: string) => void }): 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(),
|
||||
createAiSession: undefined,
|
||||
resolveProjectTaskStore: undefined,
|
||||
...({} as any),
|
||||
} as PluginContext;
|
||||
}
|
||||
|
||||
describe("report export routes", () => {
|
||||
it("returns export html with attachment header", async () => {
|
||||
const routes = createReportExportRoutes();
|
||||
const route = routes.find((r) => r.path.endsWith("export.html"))!;
|
||||
const record = report();
|
||||
const getReport = vi.fn().mockReturnValue(record);
|
||||
const setRenderedHtml = vi.fn();
|
||||
const ctx = ctxWithStore({ getReport, setRenderedHtml });
|
||||
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.contentType).toContain("text/html");
|
||||
expect(res.headers["Content-Disposition"]).toContain("attachment;");
|
||||
});
|
||||
|
||||
it("returns 404 for missing id", async () => {
|
||||
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
|
||||
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(null), setRenderedHtml: vi.fn() });
|
||||
const res = await route.handler({ params: { id: "missing" } }, ctx as any) as any;
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 409 for generating report", async () => {
|
||||
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
|
||||
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report({ status: "generating" })), setRenderedHtml: vi.fn() });
|
||||
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
|
||||
expect(res.status).toBe(409);
|
||||
});
|
||||
|
||||
it("returns body-only preview html", async () => {
|
||||
const route = createReportExportRoutes().find((r) => r.path.endsWith("preview.html"))!;
|
||||
const ctx = ctxWithStore({ getReport: vi.fn().mockReturnValue(report()), setRenderedHtml: vi.fn() });
|
||||
const res = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.contentType).toContain("text/html");
|
||||
expect(res.body).toContain("<article");
|
||||
expect(res.body).not.toContain("<!doctype html>");
|
||||
});
|
||||
|
||||
it("caches rendered html after first export", async () => {
|
||||
const route = createReportExportRoutes().find((r) => r.path.endsWith("export.html"))!;
|
||||
const mutable = report();
|
||||
const getReport = vi.fn().mockImplementation(() => mutable);
|
||||
const setRenderedHtml = vi.fn().mockImplementation((_id: string, html: string) => {
|
||||
mutable.renderedHtml = html;
|
||||
});
|
||||
const ctx = ctxWithStore({ getReport, setRenderedHtml });
|
||||
|
||||
const first = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
|
||||
const second = await route.handler({ params: { id: "rep_1" } }, ctx as any) as any;
|
||||
|
||||
expect(first.status).toBe(200);
|
||||
expect(second.status).toBe(200);
|
||||
expect(setRenderedHtml).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { PluginContext, PluginRouteDefinition, PluginRouteResponse } from "@fusion/core";
|
||||
import { ReportStore } from "../store/report-store.js";
|
||||
import { renderReportHtml } from "../render/html-template.js";
|
||||
import { renderStandaloneReportHtml, slugifyReportFilename } from "../render/standalone-html.js";
|
||||
|
||||
interface RouteRequest {
|
||||
params: Record<string, string>;
|
||||
}
|
||||
|
||||
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 notFound(message: string): PluginRouteResponse {
|
||||
return { status: 404, body: { error: message } };
|
||||
}
|
||||
|
||||
function conflict(message: string): PluginRouteResponse {
|
||||
return { status: 409, body: { error: message } };
|
||||
}
|
||||
|
||||
export function createReportExportRoutes(): PluginRouteDefinition[] {
|
||||
return [
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports/:id/export.html",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = request.params.id;
|
||||
const store = getStore(ctx);
|
||||
const record = store.getReport(id);
|
||||
if (!record) return notFound(`Report ${id} not found`);
|
||||
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
|
||||
const html = record.renderedHtml ?? renderStandaloneReportHtml(record);
|
||||
if (!record.renderedHtml) {
|
||||
store.setRenderedHtml(id, html);
|
||||
}
|
||||
return {
|
||||
status: 200,
|
||||
body: html,
|
||||
contentType: "text/html; charset=utf-8",
|
||||
headers: {
|
||||
"Content-Disposition": `attachment; filename="${slugifyReportFilename(record)}"`,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
path: "/reports/:id/preview.html",
|
||||
handler: async (req: unknown, ctx: PluginContext): Promise<PluginRouteResponse> => {
|
||||
const request = req as RouteRequest;
|
||||
const id = request.params.id;
|
||||
const store = getStore(ctx);
|
||||
const record = store.getReport(id);
|
||||
if (!record) return notFound(`Report ${id} not found`);
|
||||
if (record.status === "generating") return conflict(`Report ${id} is not generated yet`);
|
||||
return {
|
||||
status: 200,
|
||||
body: renderReportHtml(record, { includeChrome: false }),
|
||||
contentType: "text/html; charset=utf-8",
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -29,6 +29,8 @@ interface ReportRow {
|
||||
failureReason: string | null;
|
||||
draftMarkdown: string | null;
|
||||
renderedHtmlPath: string | null;
|
||||
rendered_html: string | null;
|
||||
rendered_html_generated_at: string | null;
|
||||
metadataJson: string;
|
||||
combinedReviewJson: string | null;
|
||||
createdAt: string;
|
||||
@@ -76,6 +78,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
failureReason: null,
|
||||
draftMarkdown: input.draftMarkdown ?? null,
|
||||
renderedHtmlPath: null,
|
||||
renderedHtml: null,
|
||||
renderedHtmlGeneratedAt: null,
|
||||
metadata: input.metadata ?? {},
|
||||
combinedReview: null,
|
||||
createdAt: now,
|
||||
@@ -88,12 +92,12 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
id, cadence, periodStart, periodEnd, title, status,
|
||||
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
|
||||
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
|
||||
draftMarkdown, renderedHtmlPath, metadataJson, combinedReviewJson, createdAt, updatedAt
|
||||
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,
|
||||
@draftMarkdown, @renderedHtmlPath, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
|
||||
@draftMarkdown, @renderedHtmlPath, @renderedHtml, @renderedHtmlGeneratedAt, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
|
||||
)
|
||||
`).run(this.toDbParams(report, true));
|
||||
});
|
||||
@@ -157,6 +161,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
draftMarkdown: patch.draftMarkdown ?? current.draftMarkdown,
|
||||
renderedHtmlPath: patch.renderedHtmlPath ?? current.renderedHtmlPath,
|
||||
metadata: patch.metadata ?? current.metadata,
|
||||
renderedHtml: patch.renderedHtml ?? current.renderedHtml,
|
||||
renderedHtmlGeneratedAt: patch.renderedHtmlGeneratedAt ?? current.renderedHtmlGeneratedAt,
|
||||
failureReason: patch.failureReason ?? current.failureReason,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
@@ -224,6 +230,13 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
return this.updateReport(id, { renderedHtmlPath: htmlPath });
|
||||
}
|
||||
|
||||
setRenderedHtml(id: string, html: string): Report {
|
||||
return this.updateReport(id, {
|
||||
renderedHtml: html,
|
||||
renderedHtmlGeneratedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteReport(id: string): void {
|
||||
this.requireReport(id);
|
||||
this.db.transaction(() => {
|
||||
@@ -258,6 +271,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
failureReason: row.failureReason,
|
||||
draftMarkdown: row.draftMarkdown,
|
||||
renderedHtmlPath: row.renderedHtmlPath,
|
||||
renderedHtml: row.rendered_html,
|
||||
renderedHtmlGeneratedAt: row.rendered_html_generated_at,
|
||||
metadata: this.parseMetadata(row.metadataJson),
|
||||
combinedReview: this.parseCombinedReview(row.combinedReviewJson),
|
||||
createdAt: row.createdAt,
|
||||
@@ -284,6 +299,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
failureReason = @failureReason,
|
||||
draftMarkdown = @draftMarkdown,
|
||||
renderedHtmlPath = @renderedHtmlPath,
|
||||
rendered_html = @renderedHtml,
|
||||
rendered_html_generated_at = @renderedHtmlGeneratedAt,
|
||||
metadataJson = @metadataJson,
|
||||
combinedReviewJson = @combinedReviewJson,
|
||||
updatedAt = @updatedAt
|
||||
@@ -314,6 +331,8 @@ export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||
failureReason: report.failureReason,
|
||||
draftMarkdown: report.draftMarkdown,
|
||||
renderedHtmlPath: report.renderedHtmlPath,
|
||||
renderedHtml: report.renderedHtml,
|
||||
renderedHtmlGeneratedAt: report.renderedHtmlGeneratedAt,
|
||||
metadataJson: JSON.stringify(report.metadata ?? {}),
|
||||
combinedReviewJson: report.combinedReview ? JSON.stringify(report.combinedReview) : null,
|
||||
...(includeCreatedAt ? { createdAt: report.createdAt } : {}),
|
||||
|
||||
@@ -30,6 +30,8 @@ export interface Report {
|
||||
failureReason: string | null;
|
||||
draftMarkdown: string | null;
|
||||
renderedHtmlPath: string | null;
|
||||
renderedHtml: string | null;
|
||||
renderedHtmlGeneratedAt: string | null;
|
||||
metadata: Record<string, unknown>;
|
||||
combinedReview: CombinedReview | null;
|
||||
createdAt: string;
|
||||
@@ -45,7 +47,7 @@ export interface ReportCreateInput {
|
||||
draftMarkdown?: string;
|
||||
}
|
||||
|
||||
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "metadata" | "failureReason">>;
|
||||
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "renderedHtml" | "renderedHtmlGeneratedAt" | "metadata" | "failureReason">>;
|
||||
|
||||
export interface ReportListFilter {
|
||||
cadence?: ReportCadence;
|
||||
|
||||
Reference in New Issue
Block a user