Files
fusion/plugins/fusion-plugin-reports/src/store/report-store.ts
Fusion 63fe25e014 feat(FN-3787): add approval workflow and share blocks to reports plugin
Added an approval workflow to the reports plugin comprising a state machine (`approval.ts`), share blocks logic (`share-blocks.ts`), API routes for approvals, and two new dashboard panels (ReportApprovalPanel and ShareBlocksPanel), with corresponding tests; also updated the plugin README and added a

Fusion-Task-Id: FN-3787
2026-05-10 15:34:09 -07:00

391 lines
13 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { EventEmitter } from "node:events";
import type { Database } from "@fusion/core";
import type { ApprovalDecision, ApprovalState } from "../approval.js";
import type { CombinedReview } from "../review-types.js";
import {
type Report,
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;
approval_state: ApprovalState;
approval_history: string;
draftMarkdown: string | null;
renderedHtmlPath: string | null;
rendered_html: string | null;
rendered_html_generated_at: 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,
approvalState: "not_required",
approvalHistory: [],
draftMarkdown: input.draftMarkdown ?? null,
renderedHtmlPath: null,
renderedHtml: null,
renderedHtmlGeneratedAt: 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,
approval_state, approval_history,
draftMarkdown, renderedHtmlPath, rendered_html, rendered_html_generated_at, metadataJson, combinedReviewJson, createdAt, updatedAt
) VALUES (
@id, @cadence, @periodStart, @periodEnd, @title, @status,
@generationStartedAt, @generationCompletedAt, @reviewStartedAt, @reviewCompletedAt,
@approvedAt, @approvedBy, @publishedAt, @archivedAt, @failureReason,
@approvalState, @approvalHistory,
@draftMarkdown, @renderedHtmlPath, @renderedHtml, @renderedHtmlGeneratedAt, @metadataJson, @combinedReviewJson, @createdAt, @updatedAt
)
`).run(this.toDbParams(report, true));
});
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,
renderedHtml: patch.renderedHtml ?? current.renderedHtml,
renderedHtmlGeneratedAt: patch.renderedHtmlGeneratedAt ?? current.renderedHtmlGeneratedAt,
failureReason: patch.failureReason ?? current.failureReason,
approvalState: patch.approvalState ?? current.approvalState,
approvalHistory: patch.approvalHistory ?? current.approvalHistory,
status: patch.status ?? current.status,
approvedAt: patch.approvedAt ?? current.approvedAt,
approvedBy: patch.approvedBy ?? current.approvedBy,
publishedAt: patch.publishedAt ?? current.publishedAt,
reviewCompletedAt: patch.reviewCompletedAt ?? current.reviewCompletedAt,
updatedAt: new Date().toISOString(),
};
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 });
}
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(() => {
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,
approvalState: row.approval_state,
approvalHistory: this.parseApprovalHistory(row.approval_history),
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,
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,
approval_state = @approvalState,
approval_history = @approvalHistory,
draftMarkdown = @draftMarkdown,
renderedHtmlPath = @renderedHtmlPath,
rendered_html = @renderedHtml,
rendered_html_generated_at = @renderedHtmlGeneratedAt,
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,
approvalState: report.approvalState,
approvalHistory: JSON.stringify(report.approvalHistory ?? []),
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 } : {}),
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;
}
}
private parseApprovalHistory(json: string | null): ApprovalDecision[] {
if (!json) return [];
try {
const parsed = JSON.parse(json);
return Array.isArray(parsed) ? parsed as ApprovalDecision[] : [];
} catch {
return [];
}
}
}