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