feat(FN-3784): document report archive feature in fusion-plugin-reports REA
Documents the report archive feature in the Fusion Plugin Reports README and adds a patch changeset for the release. Fusion-Task-Id: FN-3784
This commit is contained in:
5
.changeset/fn-3784-reports-archive.md
Normal file
5
.changeset/fn-3784-reports-archive.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a SQLite-backed reports archive store for the bundled reports plugin, including schema initialization, status lifecycle transitions, review attachment persistence, and typed list/filter APIs with events.
|
||||||
@@ -87,3 +87,52 @@ Aggregation is deterministic:
|
|||||||
```
|
```
|
||||||
|
|
||||||
- If all reviewers fail, combined verdict is `reject` with an explicit consensus summary describing panel failure.
|
- If all reviewers fail, combined verdict is `reject` with an explicit consensus summary describing panel failure.
|
||||||
|
|
||||||
|
## Report Archive
|
||||||
|
|
||||||
|
The plugin persists generated reports in SQLite via `ensureReportSchema(db)` and `ReportStore`.
|
||||||
|
|
||||||
|
### Schema
|
||||||
|
|
||||||
|
Table: `reports`
|
||||||
|
|
||||||
|
- identity/metadata: `id`, `cadence`, `title`, `metadataJson`
|
||||||
|
- period window: `periodStart`, `periodEnd`
|
||||||
|
- lifecycle/status: `status`, `failureReason`
|
||||||
|
- payload references: `draftMarkdown`, `renderedHtmlPath`
|
||||||
|
- review payload: `combinedReviewJson`
|
||||||
|
- timestamps: `generationStartedAt`, `generationCompletedAt`, `reviewStartedAt`, `reviewCompletedAt`, `approvedAt`, `publishedAt`, `archivedAt`, `createdAt`, `updatedAt`
|
||||||
|
- approval actor: `approvedBy`
|
||||||
|
|
||||||
|
Indexes:
|
||||||
|
|
||||||
|
- `idxReportsCadenceCreated` on `(cadence, createdAt DESC, id)`
|
||||||
|
- `idxReportsStatusUpdated` on `(status, updatedAt DESC, id)`
|
||||||
|
- `idxReportsPeriod` on `(periodStart, periodEnd, id)`
|
||||||
|
|
||||||
|
### Status lifecycle
|
||||||
|
|
||||||
|
`generating → review_pending → review_in_progress → review_complete → approved → published`
|
||||||
|
|
||||||
|
`failed` and `archived` are allowed from any non-terminal state. Idempotent transitions (`from === to`) are no-ops.
|
||||||
|
|
||||||
|
### ReportStore API
|
||||||
|
|
||||||
|
- `createReport(input)`
|
||||||
|
- `getReport(id)`
|
||||||
|
- `listReports(filter?)`
|
||||||
|
- `updateReport(id, patch)`
|
||||||
|
- `setStatus(id, next, opts?)`
|
||||||
|
- `attachReview(id, combinedReview)`
|
||||||
|
- `attachRenderedHtml(id, htmlPath)`
|
||||||
|
- `deleteReport(id)`
|
||||||
|
|
||||||
|
Emitted events:
|
||||||
|
|
||||||
|
- `report:created`
|
||||||
|
- `report:updated`
|
||||||
|
- `report:status-changed`
|
||||||
|
- `report:review-attached`
|
||||||
|
- `report:deleted`
|
||||||
|
|
||||||
|
This archive is the source of truth for downstream report HTML rendering (FN-3785) and dashboard report list/detail flows (FN-3786).
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
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 { runReviewPanel } from "./review-panel.js";
|
import { runReviewPanel } from "./review-panel.js";
|
||||||
|
import { ensureReportSchema } from "./report-schema.js";
|
||||||
import type { CombinedReview, ReviewPanelMember, RunReviewPanelInput } from "./review-types.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";
|
||||||
import { settingsSchema } from "./settings.js";
|
import { settingsSchema } from "./settings.js";
|
||||||
|
|
||||||
const plugin = definePlugin({
|
const plugin = definePlugin({
|
||||||
@@ -15,7 +18,9 @@ const plugin = definePlugin({
|
|||||||
settingsSchema,
|
settingsSchema,
|
||||||
},
|
},
|
||||||
state: "installed",
|
state: "installed",
|
||||||
hooks: {},
|
hooks: {
|
||||||
|
onSchemaInit: ensureReportSchema,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export interface RunGeneratedReportReviewInput {
|
export interface RunGeneratedReportReviewInput {
|
||||||
@@ -25,13 +30,51 @@ export interface RunGeneratedReportReviewInput {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const reportStoreCache = new WeakMap<object, ReportStore>();
|
||||||
|
|
||||||
|
export function getReportStore(ctx: PluginContext): ReportStore {
|
||||||
|
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 toCadence(cadence: RunReviewPanelInput["reportMetadata"]["cadence"]): ReportCadence {
|
||||||
|
return cadence;
|
||||||
|
}
|
||||||
|
|
||||||
export async function runGeneratedReportReview(input: RunGeneratedReportReviewInput, ctx: PluginContext): Promise<CombinedReview> {
|
export async function runGeneratedReportReview(input: RunGeneratedReportReviewInput, ctx: PluginContext): Promise<CombinedReview> {
|
||||||
return runReviewPanel({
|
const store = getReportStore(ctx);
|
||||||
|
const reportInput: ReportCreateInput = {
|
||||||
|
cadence: toCadence(input.reportMetadata.cadence),
|
||||||
|
periodStart: input.reportMetadata.periodStart,
|
||||||
|
periodEnd: input.reportMetadata.periodEnd,
|
||||||
|
title: `Generated ${input.reportMetadata.cadence} report`,
|
||||||
|
draftMarkdown: input.reportDraft,
|
||||||
|
metadata: {
|
||||||
|
reportMetadata: input.reportMetadata,
|
||||||
|
source: "runGeneratedReportReview",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const report = store.createReport(reportInput);
|
||||||
|
store.setStatus(report.id, "review_pending");
|
||||||
|
store.setStatus(report.id, "review_in_progress");
|
||||||
|
|
||||||
|
const combinedReview = await runReviewPanel({
|
||||||
reportDraft: input.reportDraft,
|
reportDraft: input.reportDraft,
|
||||||
reportMetadata: input.reportMetadata,
|
reportMetadata: {
|
||||||
|
...input.reportMetadata,
|
||||||
|
reportId: report.id,
|
||||||
|
},
|
||||||
panel: input.panel,
|
panel: input.panel,
|
||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
}, ctx);
|
}, ctx);
|
||||||
|
|
||||||
|
store.attachReview(report.id, combinedReview);
|
||||||
|
return combinedReview;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default plugin;
|
export default plugin;
|
||||||
@@ -39,3 +82,6 @@ export default plugin;
|
|||||||
export * from "./settings.js";
|
export * from "./settings.js";
|
||||||
export * from "./review-types.js";
|
export * from "./review-types.js";
|
||||||
export * from "./review-panel.js";
|
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";
|
||||||
|
|||||||
38
plugins/fusion-plugin-reports/src/report-schema.ts
Normal file
38
plugins/fusion-plugin-reports/src/report-schema.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { Database } from "@fusion/core";
|
||||||
|
|
||||||
|
export function ensureReportSchema(db: Database): void {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS reports (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
cadence TEXT NOT NULL CHECK (cadence IN ('daily','weekly','monthly','quarterly','manual')),
|
||||||
|
periodStart TEXT NOT NULL,
|
||||||
|
periodEnd TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('generating','review_pending','review_in_progress','review_complete','approved','published','archived','failed')),
|
||||||
|
generationStartedAt TEXT NOT NULL,
|
||||||
|
generationCompletedAt TEXT,
|
||||||
|
reviewStartedAt TEXT,
|
||||||
|
reviewCompletedAt TEXT,
|
||||||
|
approvedAt TEXT,
|
||||||
|
approvedBy TEXT,
|
||||||
|
publishedAt TEXT,
|
||||||
|
archivedAt TEXT,
|
||||||
|
failureReason TEXT,
|
||||||
|
draftMarkdown TEXT,
|
||||||
|
renderedHtmlPath TEXT,
|
||||||
|
metadataJson TEXT NOT NULL DEFAULT '{}',
|
||||||
|
combinedReviewJson TEXT,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxReportsCadenceCreated
|
||||||
|
ON reports(cadence, createdAt DESC, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxReportsStatusUpdated
|
||||||
|
ON reports(status, updatedAt DESC, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idxReportsPeriod
|
||||||
|
ON reports(periodStart, periodEnd, id);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Database } from "@fusion/core";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { ensureReportSchema } from "../../report-schema.js";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "report-schema-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ensureReportSchema", () => {
|
||||||
|
let tmp: string;
|
||||||
|
let db: Database;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = makeTmpDir();
|
||||||
|
db = new Database(join(tmp, ".fusion"), { inMemory: true });
|
||||||
|
db.init();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
db.close();
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates reports table and indexes idempotently", () => {
|
||||||
|
ensureReportSchema(db);
|
||||||
|
ensureReportSchema(db);
|
||||||
|
|
||||||
|
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='reports'").get() as { name: string } | undefined;
|
||||||
|
expect(table?.name).toBe("reports");
|
||||||
|
|
||||||
|
const indexes = db.prepare("SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='reports' ORDER BY name").all() as Array<{ name: string }>;
|
||||||
|
expect(indexes.map((row) => row.name)).toEqual(expect.arrayContaining([
|
||||||
|
"idxReportsCadenceCreated",
|
||||||
|
"idxReportsStatusUpdated",
|
||||||
|
"idxReportsPeriod",
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("enforces cadence and status CHECK constraints", () => {
|
||||||
|
ensureReportSchema(db);
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
id: "rep_1",
|
||||||
|
cadence: "daily",
|
||||||
|
periodStart: "2026-05-08T00:00:00.000Z",
|
||||||
|
periodEnd: "2026-05-08T23:59:59.999Z",
|
||||||
|
title: "Daily Report",
|
||||||
|
status: "generating",
|
||||||
|
generationStartedAt: "2026-05-09T00:00:00.000Z",
|
||||||
|
createdAt: "2026-05-09T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-05-09T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const stmt = db.prepare(`
|
||||||
|
INSERT INTO reports (id, cadence, periodStart, periodEnd, title, status, generationStartedAt, createdAt, updatedAt)
|
||||||
|
VALUES (@id, @cadence, @periodStart, @periodEnd, @title, @status, @generationStartedAt, @createdAt, @updatedAt)
|
||||||
|
`);
|
||||||
|
|
||||||
|
expect(() => stmt.run({ ...base, id: "rep_bad_cadence", cadence: "hourly" })).toThrow();
|
||||||
|
expect(() => stmt.run({ ...base, id: "rep_bad_status", status: "queued" })).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
import { Database } from "@fusion/core";
|
||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { ensureReportSchema } from "../../report-schema.js";
|
||||||
|
import type { CombinedReview } from "../../review-types.js";
|
||||||
|
import { ReportStore, ReportStoreError } from "../report-store.js";
|
||||||
|
|
||||||
|
function makeTmpDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "report-store-test-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeReview(): CombinedReview {
|
||||||
|
return {
|
||||||
|
overallVerdict: "revise",
|
||||||
|
consensusSummary: "Needs updates",
|
||||||
|
mergedHighlights: ["Good structure"],
|
||||||
|
mergedLowlights: ["Missing metrics"],
|
||||||
|
mergedSuggestions: ["Add numbers"],
|
||||||
|
individual: [],
|
||||||
|
failures: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ReportStore", () => {
|
||||||
|
let tmp: string;
|
||||||
|
let db: Database;
|
||||||
|
let store: ReportStore;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
tmp = makeTmpDir();
|
||||||
|
db = new Database(join(tmp, ".fusion"), { inMemory: true });
|
||||||
|
db.init();
|
||||||
|
ensureReportSchema(db);
|
||||||
|
store = new ReportStore(db);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
db.close();
|
||||||
|
await rm(tmp, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("createReport persists generating report", () => {
|
||||||
|
const listener = vi.fn();
|
||||||
|
store.on("report:created", listener);
|
||||||
|
|
||||||
|
const report = store.createReport({
|
||||||
|
cadence: "daily",
|
||||||
|
periodStart: "2026-05-01T00:00:00.000Z",
|
||||||
|
periodEnd: "2026-05-01T23:59:59.999Z",
|
||||||
|
title: "Daily",
|
||||||
|
metadata: { sourceCount: 12 },
|
||||||
|
draftMarkdown: "# Draft",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(report.id).toMatch(/^rep_/);
|
||||||
|
expect(report.status).toBe("generating");
|
||||||
|
expect(report.generationStartedAt).toBeTruthy();
|
||||||
|
expect(listener).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.getReport(report.id)?.metadata).toEqual({ sourceCount: 12 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getReport hydrates metadata and combinedReview", () => {
|
||||||
|
const report = store.createReport({ cadence: "weekly", periodStart: "2026-05-01", periodEnd: "2026-05-07", title: "Weekly" });
|
||||||
|
store.setStatus(report.id, "review_pending");
|
||||||
|
store.setStatus(report.id, "review_in_progress");
|
||||||
|
store.attachReview(report.id, makeReview());
|
||||||
|
|
||||||
|
const hydrated = store.getReport(report.id);
|
||||||
|
expect(hydrated?.combinedReview?.overallVerdict).toBe("revise");
|
||||||
|
expect(hydrated?.metadata).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("listReports filters and paginates", () => {
|
||||||
|
const a = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
||||||
|
const b = store.createReport({ cadence: "weekly", periodStart: "2026-05-02", periodEnd: "2026-05-08", title: "B" });
|
||||||
|
const c = store.createReport({ cadence: "daily", periodStart: "2026-05-03", periodEnd: "2026-05-03", title: "C" });
|
||||||
|
store.setStatus(c.id, "failed", { failureReason: "x" });
|
||||||
|
|
||||||
|
expect(store.listReports({ cadence: "daily" }).length).toBe(2);
|
||||||
|
expect(store.listReports({ statusIn: ["failed"] }).map((r) => r.id)).toEqual([c.id]);
|
||||||
|
expect(store.listReports({ periodStartFrom: "2026-05-02", periodStartTo: "2026-05-03", orderBy: "periodStart", orderDir: "asc" }).map((r) => r.id)).toEqual([b.id, c.id]);
|
||||||
|
|
||||||
|
const paged = store.listReports({ orderBy: "periodStart", orderDir: "asc", limit: 1, offset: 1 });
|
||||||
|
expect(paged).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setStatus enforces lifecycle and timestamps", () => {
|
||||||
|
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
||||||
|
|
||||||
|
const pending = store.setStatus(report.id, "review_pending");
|
||||||
|
expect(pending.generationCompletedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const inProgress = store.setStatus(report.id, "review_in_progress");
|
||||||
|
expect(inProgress.reviewStartedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const complete = store.setStatus(report.id, "review_complete");
|
||||||
|
expect(complete.reviewCompletedAt).toBeTruthy();
|
||||||
|
|
||||||
|
const approved = store.setStatus(report.id, "approved", { approvedBy: "agent-1" });
|
||||||
|
expect(approved.approvedAt).toBeTruthy();
|
||||||
|
expect(approved.approvedBy).toBe("agent-1");
|
||||||
|
|
||||||
|
const published = store.setStatus(report.id, "published");
|
||||||
|
expect(published.publishedAt).toBeTruthy();
|
||||||
|
|
||||||
|
expect(() => store.setStatus(report.id, "generating")).toThrow(ReportStoreError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("setStatus failed works from non-terminal state and saves failureReason", () => {
|
||||||
|
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
||||||
|
const failed = store.setStatus(report.id, "failed", { failureReason: "timeout" });
|
||||||
|
expect(failed.failureReason).toBe("timeout");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attachRenderedHtml and deleteReport persist and emit events", () => {
|
||||||
|
const report = store.createReport({ cadence: "manual", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
||||||
|
const deleted = vi.fn();
|
||||||
|
store.on("report:deleted", deleted);
|
||||||
|
|
||||||
|
const updated = store.attachRenderedHtml(report.id, ".fusion/plugins/reports/report.html");
|
||||||
|
expect(updated.renderedHtmlPath).toContain("report.html");
|
||||||
|
|
||||||
|
store.deleteReport(report.id);
|
||||||
|
expect(store.getReport(report.id)).toBeNull();
|
||||||
|
expect(deleted).toHaveBeenCalledWith(report.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits events once for update/status/review", () => {
|
||||||
|
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
||||||
|
const updated = vi.fn();
|
||||||
|
const status = vi.fn();
|
||||||
|
const review = vi.fn();
|
||||||
|
store.on("report:updated", updated);
|
||||||
|
store.on("report:status-changed", status);
|
||||||
|
store.on("report:review-attached", review);
|
||||||
|
|
||||||
|
store.updateReport(report.id, { title: "B" });
|
||||||
|
store.setStatus(report.id, "review_pending");
|
||||||
|
store.setStatus(report.id, "review_in_progress");
|
||||||
|
store.attachReview(report.id, makeReview());
|
||||||
|
|
||||||
|
expect(updated).toHaveBeenCalledTimes(1);
|
||||||
|
expect(status).toHaveBeenCalledTimes(3);
|
||||||
|
expect(review).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rolls back failed transaction and commits successful transaction", () => {
|
||||||
|
const report = store.createReport({ cadence: "daily", periodStart: "2026-05-01", periodEnd: "2026-05-01", title: "A" });
|
||||||
|
|
||||||
|
const originalPrepare = db.prepare.bind(db);
|
||||||
|
const spy = vi.spyOn(db, "prepare").mockImplementation((sql: string) => {
|
||||||
|
const stmt = originalPrepare(sql);
|
||||||
|
if (sql.includes("UPDATE reports") && !sql.includes("WHERE id = @id")) {
|
||||||
|
return stmt;
|
||||||
|
}
|
||||||
|
if (sql.includes("UPDATE reports")) {
|
||||||
|
return {
|
||||||
|
...stmt,
|
||||||
|
run: (...args: unknown[]) => {
|
||||||
|
throw new Error("forced failure");
|
||||||
|
},
|
||||||
|
} as typeof stmt;
|
||||||
|
}
|
||||||
|
return stmt;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(() => store.updateReport(report.id, { title: "Broken" })).toThrow("forced failure");
|
||||||
|
expect(store.getReport(report.id)?.title).toBe("A");
|
||||||
|
|
||||||
|
spy.mockRestore();
|
||||||
|
const ok = store.updateReport(report.id, { title: "Good" });
|
||||||
|
expect(ok.title).toBe("Good");
|
||||||
|
});
|
||||||
|
});
|
||||||
341
plugins/fusion-plugin-reports/src/store/report-store.ts
Normal file
341
plugins/fusion-plugin-reports/src/store/report-store.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import type { Database } from "@fusion/core";
|
||||||
|
import type { CombinedReview } from "../review-types.js";
|
||||||
|
import {
|
||||||
|
type Report,
|
||||||
|
type ReportCreateInput,
|
||||||
|
type ReportListFilter,
|
||||||
|
type ReportStatus,
|
||||||
|
type ReportUpdateInput,
|
||||||
|
isValidReportStatusTransition,
|
||||||
|
} from "./report-types.js";
|
||||||
|
|
||||||
|
interface ReportRow {
|
||||||
|
id: string;
|
||||||
|
cadence: Report["cadence"];
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
title: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
generationStartedAt: string;
|
||||||
|
generationCompletedAt: string | null;
|
||||||
|
reviewStartedAt: string | null;
|
||||||
|
reviewCompletedAt: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
approvedBy: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
failureReason: string | null;
|
||||||
|
draftMarkdown: string | null;
|
||||||
|
renderedHtmlPath: string | null;
|
||||||
|
metadataJson: string;
|
||||||
|
combinedReviewJson: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportStoreEvents {
|
||||||
|
"report:created": [Report];
|
||||||
|
"report:updated": [Report];
|
||||||
|
"report:status-changed": [Report];
|
||||||
|
"report:review-attached": [Report];
|
||||||
|
"report:deleted": [string];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportStoreError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ReportStoreError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ReportStore extends EventEmitter<ReportStoreEvents> {
|
||||||
|
constructor(private readonly db: Database) {
|
||||||
|
super();
|
||||||
|
this.setMaxListeners(50);
|
||||||
|
}
|
||||||
|
|
||||||
|
createReport(input: ReportCreateInput): Report {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const report: Report = {
|
||||||
|
id: `rep_${randomUUID().replaceAll("-", "")}`,
|
||||||
|
cadence: input.cadence,
|
||||||
|
periodStart: input.periodStart,
|
||||||
|
periodEnd: input.periodEnd,
|
||||||
|
title: input.title,
|
||||||
|
status: "generating",
|
||||||
|
generationStartedAt: now,
|
||||||
|
generationCompletedAt: null,
|
||||||
|
reviewStartedAt: null,
|
||||||
|
reviewCompletedAt: null,
|
||||||
|
approvedAt: null,
|
||||||
|
approvedBy: null,
|
||||||
|
publishedAt: null,
|
||||||
|
archivedAt: null,
|
||||||
|
failureReason: null,
|
||||||
|
draftMarkdown: input.draftMarkdown ?? null,
|
||||||
|
renderedHtmlPath: null,
|
||||||
|
metadata: input.metadata ?? {},
|
||||||
|
combinedReview: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.transaction(() => {
|
||||||
|
this.db.prepare(`
|
||||||
|
INSERT INTO reports (
|
||||||
|
id, cadence, periodStart, periodEnd, title, status,
|
||||||
|
generationStartedAt, generationCompletedAt, reviewStartedAt, reviewCompletedAt,
|
||||||
|
approvedAt, approvedBy, publishedAt, archivedAt, failureReason,
|
||||||
|
draftMarkdown, renderedHtmlPath, 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
|
||||||
|
)
|
||||||
|
`).run(this.toDbParams(report, true));
|
||||||
|
});
|
||||||
|
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("report:created", report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
getReport(id: string): Report | null {
|
||||||
|
const row = this.db.prepare("SELECT * FROM reports WHERE id = ?").get(id) as ReportRow | undefined;
|
||||||
|
return row ? this.rowToReport(row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
listReports(filter: ReportListFilter = {}): Report[] {
|
||||||
|
const params: unknown[] = [];
|
||||||
|
const where: string[] = [];
|
||||||
|
|
||||||
|
if (filter.cadence) {
|
||||||
|
where.push("cadence = ?");
|
||||||
|
params.push(filter.cadence);
|
||||||
|
}
|
||||||
|
if (filter.statusIn && filter.statusIn.length > 0) {
|
||||||
|
where.push(`status IN (${filter.statusIn.map(() => "?").join(",")})`);
|
||||||
|
params.push(...filter.statusIn);
|
||||||
|
} else if (filter.status) {
|
||||||
|
where.push("status = ?");
|
||||||
|
params.push(filter.status);
|
||||||
|
}
|
||||||
|
if (filter.periodStartFrom) {
|
||||||
|
where.push("periodStart >= ?");
|
||||||
|
params.push(filter.periodStartFrom);
|
||||||
|
}
|
||||||
|
if (filter.periodStartTo) {
|
||||||
|
where.push("periodStart <= ?");
|
||||||
|
params.push(filter.periodStartTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderBy = filter.orderBy === "periodStart" ? "periodStart" : "createdAt";
|
||||||
|
const orderDir = filter.orderDir === "asc" ? "ASC" : "DESC";
|
||||||
|
const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500);
|
||||||
|
const offset = Math.max(filter.offset ?? 0, 0);
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT * FROM reports
|
||||||
|
${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
|
||||||
|
ORDER BY ${orderBy} ${orderDir}, id ${orderDir}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`;
|
||||||
|
params.push(limit, offset);
|
||||||
|
|
||||||
|
const rows = this.db.prepare(sql).all(...params) as ReportRow[];
|
||||||
|
return rows.map((row) => this.rowToReport(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateReport(id: string, patch: ReportUpdateInput): Report {
|
||||||
|
const current = this.requireReport(id);
|
||||||
|
const next: Report = {
|
||||||
|
...current,
|
||||||
|
title: patch.title ?? current.title,
|
||||||
|
draftMarkdown: patch.draftMarkdown ?? current.draftMarkdown,
|
||||||
|
renderedHtmlPath: patch.renderedHtmlPath ?? current.renderedHtmlPath,
|
||||||
|
metadata: patch.metadata ?? current.metadata,
|
||||||
|
failureReason: patch.failureReason ?? current.failureReason,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.transaction(() => this.persistExisting(next));
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("report:updated", next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(id: string, next: ReportStatus, opts: { failureReason?: string; approvedBy?: string } = {}): Report {
|
||||||
|
const current = this.requireReport(id);
|
||||||
|
if (current.status === next) return current;
|
||||||
|
if (!isValidReportStatusTransition(current.status, next)) {
|
||||||
|
throw new ReportStoreError(`Invalid status transition: ${current.status} -> ${next}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updated: Report = {
|
||||||
|
...current,
|
||||||
|
status: next,
|
||||||
|
updatedAt: now,
|
||||||
|
failureReason: next === "failed" ? (opts.failureReason ?? current.failureReason) : current.failureReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (next === "review_pending") updated.generationCompletedAt = now;
|
||||||
|
if (next === "review_in_progress") updated.reviewStartedAt = now;
|
||||||
|
if (next === "review_complete") updated.reviewCompletedAt = now;
|
||||||
|
if (next === "approved") {
|
||||||
|
updated.approvedAt = now;
|
||||||
|
updated.approvedBy = opts.approvedBy ?? current.approvedBy;
|
||||||
|
}
|
||||||
|
if (next === "published") updated.publishedAt = now;
|
||||||
|
if (next === "archived") updated.archivedAt = now;
|
||||||
|
|
||||||
|
this.db.transaction(() => this.persistExisting(updated));
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("report:status-changed", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachReview(id: string, combined: CombinedReview): Report {
|
||||||
|
const current = this.requireReport(id);
|
||||||
|
if (current.status !== "review_in_progress") {
|
||||||
|
throw new ReportStoreError(`attachReview requires review_in_progress status; got ${current.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const updated: Report = {
|
||||||
|
...current,
|
||||||
|
combinedReview: combined,
|
||||||
|
status: "review_complete",
|
||||||
|
reviewCompletedAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.db.transaction(() => this.persistExisting(updated));
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("report:review-attached", updated);
|
||||||
|
this.emit("report:status-changed", updated);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachRenderedHtml(id: string, htmlPath: string): Report {
|
||||||
|
return this.updateReport(id, { renderedHtmlPath: htmlPath });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteReport(id: string): void {
|
||||||
|
this.requireReport(id);
|
||||||
|
this.db.transaction(() => {
|
||||||
|
this.db.prepare("DELETE FROM reports WHERE id = ?").run(id);
|
||||||
|
});
|
||||||
|
this.db.bumpLastModified();
|
||||||
|
this.emit("report:deleted", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireReport(id: string): Report {
|
||||||
|
const report = this.getReport(id);
|
||||||
|
if (!report) throw new ReportStoreError(`Report ${id} not found`);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToReport(row: ReportRow): Report {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
cadence: row.cadence,
|
||||||
|
periodStart: row.periodStart,
|
||||||
|
periodEnd: row.periodEnd,
|
||||||
|
title: row.title,
|
||||||
|
status: row.status,
|
||||||
|
generationStartedAt: row.generationStartedAt,
|
||||||
|
generationCompletedAt: row.generationCompletedAt,
|
||||||
|
reviewStartedAt: row.reviewStartedAt,
|
||||||
|
reviewCompletedAt: row.reviewCompletedAt,
|
||||||
|
approvedAt: row.approvedAt,
|
||||||
|
approvedBy: row.approvedBy,
|
||||||
|
publishedAt: row.publishedAt,
|
||||||
|
archivedAt: row.archivedAt,
|
||||||
|
failureReason: row.failureReason,
|
||||||
|
draftMarkdown: row.draftMarkdown,
|
||||||
|
renderedHtmlPath: row.renderedHtmlPath,
|
||||||
|
metadata: this.parseMetadata(row.metadataJson),
|
||||||
|
combinedReview: this.parseCombinedReview(row.combinedReviewJson),
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private persistExisting(report: Report): void {
|
||||||
|
const result = this.db.prepare(`
|
||||||
|
UPDATE reports
|
||||||
|
SET cadence = @cadence,
|
||||||
|
periodStart = @periodStart,
|
||||||
|
periodEnd = @periodEnd,
|
||||||
|
title = @title,
|
||||||
|
status = @status,
|
||||||
|
generationStartedAt = @generationStartedAt,
|
||||||
|
generationCompletedAt = @generationCompletedAt,
|
||||||
|
reviewStartedAt = @reviewStartedAt,
|
||||||
|
reviewCompletedAt = @reviewCompletedAt,
|
||||||
|
approvedAt = @approvedAt,
|
||||||
|
approvedBy = @approvedBy,
|
||||||
|
publishedAt = @publishedAt,
|
||||||
|
archivedAt = @archivedAt,
|
||||||
|
failureReason = @failureReason,
|
||||||
|
draftMarkdown = @draftMarkdown,
|
||||||
|
renderedHtmlPath = @renderedHtmlPath,
|
||||||
|
metadataJson = @metadataJson,
|
||||||
|
combinedReviewJson = @combinedReviewJson,
|
||||||
|
updatedAt = @updatedAt
|
||||||
|
WHERE id = @id
|
||||||
|
`).run(this.toDbParams(report, false));
|
||||||
|
|
||||||
|
if (result.changes === 0) {
|
||||||
|
throw new ReportStoreError(`Report ${report.id} not found`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toDbParams(report: Report, includeCreatedAt: boolean): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: report.id,
|
||||||
|
cadence: report.cadence,
|
||||||
|
periodStart: report.periodStart,
|
||||||
|
periodEnd: report.periodEnd,
|
||||||
|
title: report.title,
|
||||||
|
status: report.status,
|
||||||
|
generationStartedAt: report.generationStartedAt,
|
||||||
|
generationCompletedAt: report.generationCompletedAt,
|
||||||
|
reviewStartedAt: report.reviewStartedAt,
|
||||||
|
reviewCompletedAt: report.reviewCompletedAt,
|
||||||
|
approvedAt: report.approvedAt,
|
||||||
|
approvedBy: report.approvedBy,
|
||||||
|
publishedAt: report.publishedAt,
|
||||||
|
archivedAt: report.archivedAt,
|
||||||
|
failureReason: report.failureReason,
|
||||||
|
draftMarkdown: report.draftMarkdown,
|
||||||
|
renderedHtmlPath: report.renderedHtmlPath,
|
||||||
|
metadataJson: JSON.stringify(report.metadata ?? {}),
|
||||||
|
combinedReviewJson: report.combinedReview ? JSON.stringify(report.combinedReview) : null,
|
||||||
|
...(includeCreatedAt ? { createdAt: report.createdAt } : {}),
|
||||||
|
updatedAt: report.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseMetadata(json: string): Record<string, unknown> {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(json);
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseCombinedReview(json: string | null): CombinedReview | null {
|
||||||
|
if (!json) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(json) as CombinedReview;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
77
plugins/fusion-plugin-reports/src/store/report-types.ts
Normal file
77
plugins/fusion-plugin-reports/src/store/report-types.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import type { CombinedReview } from "../review-types.js";
|
||||||
|
|
||||||
|
export type ReportCadence = "daily" | "weekly" | "monthly" | "quarterly" | "manual";
|
||||||
|
|
||||||
|
export type ReportStatus =
|
||||||
|
| "generating"
|
||||||
|
| "review_pending"
|
||||||
|
| "review_in_progress"
|
||||||
|
| "review_complete"
|
||||||
|
| "approved"
|
||||||
|
| "published"
|
||||||
|
| "archived"
|
||||||
|
| "failed";
|
||||||
|
|
||||||
|
export interface Report {
|
||||||
|
id: string;
|
||||||
|
cadence: ReportCadence;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
title: string;
|
||||||
|
status: ReportStatus;
|
||||||
|
generationStartedAt: string;
|
||||||
|
generationCompletedAt: string | null;
|
||||||
|
reviewStartedAt: string | null;
|
||||||
|
reviewCompletedAt: string | null;
|
||||||
|
approvedAt: string | null;
|
||||||
|
approvedBy: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
archivedAt: string | null;
|
||||||
|
failureReason: string | null;
|
||||||
|
draftMarkdown: string | null;
|
||||||
|
renderedHtmlPath: string | null;
|
||||||
|
metadata: Record<string, unknown>;
|
||||||
|
combinedReview: CombinedReview | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportCreateInput {
|
||||||
|
cadence: ReportCadence;
|
||||||
|
periodStart: string;
|
||||||
|
periodEnd: string;
|
||||||
|
title: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
draftMarkdown?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportUpdateInput = Partial<Pick<Report, "title" | "draftMarkdown" | "renderedHtmlPath" | "metadata" | "failureReason">>;
|
||||||
|
|
||||||
|
export interface ReportListFilter {
|
||||||
|
cadence?: ReportCadence;
|
||||||
|
status?: ReportStatus;
|
||||||
|
statusIn?: ReportStatus[];
|
||||||
|
periodStartFrom?: string;
|
||||||
|
periodStartTo?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
orderBy?: "createdAt" | "periodStart";
|
||||||
|
orderDir?: "asc" | "desc";
|
||||||
|
}
|
||||||
|
|
||||||
|
const TERMINAL_STATUSES = new Set<ReportStatus>(["published", "archived", "failed"]);
|
||||||
|
const LINEAR_TRANSITIONS: Record<Exclude<ReportStatus, "published" | "archived" | "failed">, ReportStatus> = {
|
||||||
|
generating: "review_pending",
|
||||||
|
review_pending: "review_in_progress",
|
||||||
|
review_in_progress: "review_complete",
|
||||||
|
review_complete: "approved",
|
||||||
|
approved: "published",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isValidReportStatusTransition(from: ReportStatus, to: ReportStatus): boolean {
|
||||||
|
if (from === to) return true;
|
||||||
|
if (TERMINAL_STATUSES.has(from)) return false;
|
||||||
|
if (to === "failed" || to === "archived") return true;
|
||||||
|
if (!(from in LINEAR_TRANSITIONS)) return false;
|
||||||
|
return LINEAR_TRANSITIONS[from as keyof typeof LINEAR_TRANSITIONS] === to;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user