mirror of
https://github.com/BradGroux/veritas-kanban.git
synced 2026-10-07 04:07:50 +00:00
feat: add branded PDF report generation with templates and brand config (closes #90)
Inspired by @nateherk's Klouse branded PDF reports. - 5 report templates: audit, summary, analysis, standup, custom - Brand config: company name, logo, colors, font, tagline - Markdown → HTML conversion with print-optimized CSS - Template-specific styles (audit findings, metrics cards, status colors) - HTML served directly or printable to PDF via browser - Reports stored in docs/reports/ and accessible via docs tab - REST API: generate, list, get, brand CRUD, templates, HTML serve
This commit is contained in:
parent
0ade973d7d
commit
5ea3e58964
3 changed files with 504 additions and 0 deletions
149
server/src/routes/reports.ts
Normal file
149
server/src/routes/reports.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
/**
|
||||
* PDF/Report Generation API Routes
|
||||
*
|
||||
* POST /api/reports/generate — Generate a branded report
|
||||
* GET /api/reports — List generated reports
|
||||
* GET /api/reports/templates — Available report templates
|
||||
* GET /api/reports/brand — Get brand config
|
||||
* PUT /api/reports/brand — Update brand config
|
||||
* GET /api/reports/:id — Get specific report
|
||||
* GET /api/reports/:id/html — Get report HTML content
|
||||
*/
|
||||
|
||||
import { Router, type Router as RouterType } from 'express';
|
||||
import { z } from 'zod';
|
||||
import { getPdfReportService } from '../services/pdf-report-service.js';
|
||||
import { asyncHandler } from '../middleware/async-handler.js';
|
||||
import { NotFoundError } from '../middleware/error-handler.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
const router: RouterType = Router();
|
||||
|
||||
/**
|
||||
* GET /api/reports/templates
|
||||
*/
|
||||
router.get(
|
||||
'/templates',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const service = getPdfReportService();
|
||||
res.json(service.getTemplates());
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/reports/brand
|
||||
*/
|
||||
router.get(
|
||||
'/brand',
|
||||
asyncHandler(async (_req, res) => {
|
||||
const service = getPdfReportService();
|
||||
const brand = await service.getBrand();
|
||||
res.json(brand);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* PUT /api/reports/brand
|
||||
*/
|
||||
router.put(
|
||||
'/brand',
|
||||
asyncHandler(async (req, res) => {
|
||||
const schema = z.object({
|
||||
companyName: z.string().optional(),
|
||||
logoUrl: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
secondaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
fontFamily: z.string().optional(),
|
||||
tagline: z.string().optional(),
|
||||
});
|
||||
const update = schema.parse(req.body);
|
||||
const service = getPdfReportService();
|
||||
const brand = await service.updateBrand(update);
|
||||
res.json(brand);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/reports/generate
|
||||
*/
|
||||
router.post(
|
||||
'/generate',
|
||||
asyncHandler(async (req, res) => {
|
||||
const schema = z.object({
|
||||
title: z.string().min(1),
|
||||
subtitle: z.string().optional(),
|
||||
template: z.enum(['audit', 'summary', 'analysis', 'standup', 'custom']),
|
||||
content: z.string().min(1),
|
||||
brand: z
|
||||
.object({
|
||||
companyName: z.string().optional(),
|
||||
logoUrl: z.string().optional(),
|
||||
primaryColor: z.string().optional(),
|
||||
secondaryColor: z.string().optional(),
|
||||
accentColor: z.string().optional(),
|
||||
fontFamily: z.string().optional(),
|
||||
tagline: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
includeToc: z.boolean().optional(),
|
||||
includeTimestamp: z.boolean().optional(),
|
||||
includePageNumbers: z.boolean().optional(),
|
||||
author: z.string().optional(),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
});
|
||||
const data = schema.parse(req.body);
|
||||
const service = getPdfReportService();
|
||||
const report = await service.generateReport(data);
|
||||
res.status(201).json(report);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/reports
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = getPdfReportService();
|
||||
const reports = await service.listReports(
|
||||
req.query.limit ? Number(req.query.limit) : undefined
|
||||
);
|
||||
res.json(reports);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/reports/:id
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = getPdfReportService();
|
||||
const report = await service.getReport(req.params.id);
|
||||
if (!report) throw new NotFoundError('Report not found');
|
||||
res.json(report);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/reports/:id/html — Serve the HTML report
|
||||
*/
|
||||
router.get(
|
||||
'/:id/html',
|
||||
asyncHandler(async (req, res) => {
|
||||
const service = getPdfReportService();
|
||||
const report = await service.getReport(req.params.id);
|
||||
if (!report) throw new NotFoundError('Report not found');
|
||||
|
||||
try {
|
||||
const html = await fs.readFile(report.htmlPath, 'utf-8');
|
||||
res.setHeader('Content-Type', 'text/html');
|
||||
res.send(html);
|
||||
} catch {
|
||||
throw new NotFoundError('Report HTML file not found');
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
export { router as reportRoutes };
|
||||
|
|
@ -55,6 +55,7 @@ import { agentPermissionRoutes } from '../agent-permissions.js';
|
|||
import { costPredictionRoutes } from '../cost-prediction.js';
|
||||
import { errorLearningRoutes } from '../error-learning.js';
|
||||
import { docsRoutes } from '../docs.js';
|
||||
import { reportRoutes } from '../reports.js';
|
||||
import { scheduledDeliverablesRoutes } from '../scheduled-deliverables.js';
|
||||
import { lifecycleHooksRoutes } from '../lifecycle-hooks.js';
|
||||
import { statusHistoryRoutes } from '../status-history.js';
|
||||
|
|
@ -129,6 +130,7 @@ v1Router.use('/agents/register', agentRegistryRoutes);
|
|||
v1Router.use('/agents/permissions', agentPermissionRoutes);
|
||||
v1Router.use('/cost-prediction', costPredictionRoutes);
|
||||
v1Router.use('/deliverables', scheduledDeliverablesRoutes);
|
||||
v1Router.use('/reports', reportRoutes);
|
||||
v1Router.use('/docs', docsRoutes);
|
||||
v1Router.use('/errors', errorLearningRoutes);
|
||||
v1Router.use('/hooks', lifecycleHooksRoutes);
|
||||
|
|
|
|||
353
server/src/services/pdf-report-service.ts
Normal file
353
server/src/services/pdf-report-service.ts
Normal file
|
|
@ -0,0 +1,353 @@
|
|||
/**
|
||||
* PDF Report Generation Service
|
||||
*
|
||||
* Generates branded PDF reports from markdown content.
|
||||
* Supports templates, brand config (logo, colors, fonts),
|
||||
* and multiple report types.
|
||||
*
|
||||
* Uses HTML → PDF approach via built-in capabilities.
|
||||
* For richer output, pptxgenjs is available for PPTX.
|
||||
*
|
||||
* Inspired by @nateherk's Klouse branded reports.
|
||||
*/
|
||||
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { getStorageBase } from '../storage/fs-helpers.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
|
||||
const log = createLogger('pdf-reports');
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface BrandConfig {
|
||||
companyName: string;
|
||||
logoUrl?: string;
|
||||
primaryColor: string;
|
||||
secondaryColor: string;
|
||||
accentColor: string;
|
||||
fontFamily: string;
|
||||
tagline?: string;
|
||||
}
|
||||
|
||||
export type ReportTemplate = 'audit' | 'summary' | 'analysis' | 'standup' | 'custom';
|
||||
|
||||
export interface ReportConfig {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
template: ReportTemplate;
|
||||
/** Markdown content */
|
||||
content: string;
|
||||
/** Brand overrides (uses default if not provided) */
|
||||
brand?: Partial<BrandConfig>;
|
||||
/** Include table of contents */
|
||||
includeToc?: boolean;
|
||||
/** Include timestamp */
|
||||
includeTimestamp?: boolean;
|
||||
/** Include page numbers */
|
||||
includePageNumbers?: boolean;
|
||||
/** Author name */
|
||||
author?: string;
|
||||
/** Additional metadata */
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GeneratedReport {
|
||||
id: string;
|
||||
title: string;
|
||||
template: ReportTemplate;
|
||||
/** HTML content (can be converted to PDF via browser print) */
|
||||
htmlPath: string;
|
||||
/** Relative path in docs */
|
||||
docsPath: string;
|
||||
/** File size */
|
||||
size: number;
|
||||
generatedAt: string;
|
||||
brand: BrandConfig;
|
||||
}
|
||||
|
||||
// ─── Default Brand ───────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_BRAND: BrandConfig = {
|
||||
companyName: 'Veritas Kanban',
|
||||
primaryColor: '#8b5cf6',
|
||||
secondaryColor: '#1e1b4b',
|
||||
accentColor: '#c4b5fd',
|
||||
fontFamily: 'Inter, system-ui, -apple-system, sans-serif',
|
||||
};
|
||||
|
||||
// ─── Template Styles ─────────────────────────────────────────────
|
||||
|
||||
function getTemplateCSS(brand: BrandConfig, template: ReportTemplate): string {
|
||||
const base = `
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: ${brand.fontFamily};
|
||||
color: #1a1a2e;
|
||||
line-height: 1.6;
|
||||
padding: 40px;
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 { color: ${brand.primaryColor}; font-size: 28px; margin-bottom: 8px; border-bottom: 3px solid ${brand.primaryColor}; padding-bottom: 12px; }
|
||||
h2 { color: ${brand.secondaryColor}; font-size: 22px; margin-top: 32px; margin-bottom: 12px; }
|
||||
h3 { color: ${brand.primaryColor}; font-size: 18px; margin-top: 24px; margin-bottom: 8px; }
|
||||
p { margin-bottom: 12px; }
|
||||
ul, ol { margin-bottom: 12px; padding-left: 24px; }
|
||||
li { margin-bottom: 4px; }
|
||||
code { background: #f3f4f6; padding: 2px 6px; border-radius: 4px; font-size: 0.9em; }
|
||||
pre { background: #1e1b4b; color: #e2e8f0; padding: 16px; border-radius: 8px; overflow-x: auto; margin-bottom: 16px; }
|
||||
pre code { background: none; color: inherit; }
|
||||
table { width: 100%; border-collapse: collapse; margin-bottom: 16px; }
|
||||
th { background: ${brand.primaryColor}; color: white; padding: 10px 12px; text-align: left; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
td { padding: 10px 12px; border-bottom: 1px solid #e5e7eb; }
|
||||
tr:nth-child(even) td { background: #f9fafb; }
|
||||
blockquote { border-left: 4px solid ${brand.accentColor}; padding: 12px 16px; background: ${brand.accentColor}10; margin-bottom: 16px; font-style: italic; }
|
||||
hr { border: none; border-top: 2px solid #e5e7eb; margin: 24px 0; }
|
||||
a { color: ${brand.primaryColor}; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.report-header { margin-bottom: 32px; }
|
||||
.report-header .logo { max-height: 48px; margin-bottom: 16px; }
|
||||
.report-header .subtitle { color: #6b7280; font-size: 16px; }
|
||||
.report-header .meta { color: #9ca3af; font-size: 12px; margin-top: 12px; }
|
||||
.report-footer { margin-top: 40px; padding-top: 16px; border-top: 2px solid ${brand.primaryColor}; color: #9ca3af; font-size: 11px; text-align: center; }
|
||||
@media print {
|
||||
body { padding: 20px; }
|
||||
.no-print { display: none; }
|
||||
}
|
||||
`;
|
||||
|
||||
const templateExtras: Record<ReportTemplate, string> = {
|
||||
audit: `
|
||||
.severity-critical { color: #dc2626; font-weight: bold; }
|
||||
.severity-high { color: #ea580c; font-weight: bold; }
|
||||
.severity-medium { color: #d97706; }
|
||||
.severity-low { color: #65a30d; }
|
||||
.finding { background: #fef2f2; border-left: 4px solid #dc2626; padding: 12px; margin-bottom: 12px; border-radius: 0 8px 8px 0; }
|
||||
`,
|
||||
summary: `
|
||||
.metric { display: inline-block; background: ${brand.primaryColor}10; border: 1px solid ${brand.accentColor}; border-radius: 8px; padding: 12px 16px; margin: 4px; text-align: center; min-width: 120px; }
|
||||
.metric-value { font-size: 24px; font-weight: bold; color: ${brand.primaryColor}; }
|
||||
.metric-label { font-size: 11px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
`,
|
||||
analysis: `
|
||||
.pro { color: #16a34a; }
|
||||
.con { color: #dc2626; }
|
||||
.recommendation { background: ${brand.primaryColor}08; border: 1px solid ${brand.accentColor}; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
|
||||
`,
|
||||
standup: `
|
||||
.status-done { color: #16a34a; }
|
||||
.status-progress { color: #2563eb; }
|
||||
.status-blocked { color: #dc2626; }
|
||||
.agent-card { background: #f8fafc; border-radius: 8px; padding: 12px; margin-bottom: 8px; border-left: 3px solid ${brand.primaryColor}; }
|
||||
`,
|
||||
custom: '',
|
||||
};
|
||||
|
||||
return base + (templateExtras[template] || '');
|
||||
}
|
||||
|
||||
// ─── Markdown to HTML (basic) ────────────────────────────────────
|
||||
|
||||
function markdownToHtml(md: string): string {
|
||||
let html = md
|
||||
// Headers
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
// Bold/italic
|
||||
.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.+?)\*/g, '<em>$1</em>')
|
||||
// Code blocks
|
||||
.replace(/```[\w]*\n([\s\S]*?)```/g, '<pre><code>$1</code></pre>')
|
||||
.replace(/`(.+?)`/g, '<code>$1</code>')
|
||||
// Blockquotes
|
||||
.replace(/^> (.+)$/gm, '<blockquote>$1</blockquote>')
|
||||
// Horizontal rules
|
||||
.replace(/^---$/gm, '<hr>')
|
||||
// Lists
|
||||
.replace(/^- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^(\d+)\. (.+)$/gm, '<li>$2</li>')
|
||||
// Links
|
||||
.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')
|
||||
// Paragraphs (lines not already wrapped)
|
||||
.replace(/^(?!<[h1-6|li|pre|blockquote|hr|ul|ol|div])(.+)$/gm, '<p>$1</p>');
|
||||
|
||||
// Wrap consecutive li elements in ul
|
||||
html = html.replace(/(<li>.*?<\/li>\n?)+/g, '<ul>$&</ul>');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
// ─── Service ─────────────────────────────────────────────────────
|
||||
|
||||
class PdfReportService {
|
||||
private brandConfig: BrandConfig = { ...DEFAULT_BRAND };
|
||||
private reports: GeneratedReport[] = [];
|
||||
private loaded = false;
|
||||
|
||||
private get configPath(): string {
|
||||
return path.join(getStorageBase(), 'report-brand.json');
|
||||
}
|
||||
|
||||
private get reportsPath(): string {
|
||||
return path.join(getStorageBase(), 'generated-reports.json');
|
||||
}
|
||||
|
||||
private get outputDir(): string {
|
||||
return path.join(getStorageBase(), '..', 'docs', 'reports');
|
||||
}
|
||||
|
||||
private async ensureLoaded(): Promise<void> {
|
||||
if (this.loaded) return;
|
||||
try {
|
||||
const data = await fs.readFile(this.configPath, 'utf-8');
|
||||
this.brandConfig = { ...DEFAULT_BRAND, ...JSON.parse(data) };
|
||||
} catch {
|
||||
// Use defaults
|
||||
}
|
||||
try {
|
||||
const data = await fs.readFile(this.reportsPath, 'utf-8');
|
||||
this.reports = JSON.parse(data);
|
||||
} catch {
|
||||
this.reports = [];
|
||||
}
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current brand config.
|
||||
*/
|
||||
async getBrand(): Promise<BrandConfig> {
|
||||
await this.ensureLoaded();
|
||||
return { ...this.brandConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update brand config.
|
||||
*/
|
||||
async updateBrand(update: Partial<BrandConfig>): Promise<BrandConfig> {
|
||||
await this.ensureLoaded();
|
||||
this.brandConfig = { ...this.brandConfig, ...update };
|
||||
await fs.writeFile(this.configPath, JSON.stringify(this.brandConfig, null, 2));
|
||||
return { ...this.brandConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a branded HTML report from markdown.
|
||||
* The HTML includes print-optimized CSS for PDF generation via browser.
|
||||
*/
|
||||
async generateReport(config: ReportConfig): Promise<GeneratedReport> {
|
||||
await this.ensureLoaded();
|
||||
|
||||
const brand = { ...this.brandConfig, ...config.brand };
|
||||
const css = getTemplateCSS(brand, config.template);
|
||||
const contentHtml = markdownToHtml(config.content);
|
||||
|
||||
const timestamp = config.includeTimestamp !== false
|
||||
? `<div class="report-header meta">Generated: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}</div>`
|
||||
: '';
|
||||
|
||||
const authorLine = config.author ? `<div class="report-header meta">Author: ${config.author}</div>` : '';
|
||||
const subtitleLine = config.subtitle ? `<div class="report-header subtitle">${config.subtitle}</div>` : '';
|
||||
|
||||
const logoHtml = brand.logoUrl
|
||||
? `<img src="${brand.logoUrl}" class="logo" alt="${brand.companyName}" />`
|
||||
: '';
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${config.title} — ${brand.companyName}</title>
|
||||
<style>${css}</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="report-header">
|
||||
${logoHtml}
|
||||
<h1>${config.title}</h1>
|
||||
${subtitleLine}
|
||||
${timestamp}
|
||||
${authorLine}
|
||||
</div>
|
||||
|
||||
<div class="report-content">
|
||||
${contentHtml}
|
||||
</div>
|
||||
|
||||
<div class="report-footer">
|
||||
${brand.companyName}${brand.tagline ? ` — ${brand.tagline}` : ''} · Generated by Veritas Kanban
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Save HTML file
|
||||
await fs.mkdir(this.outputDir, { recursive: true });
|
||||
const fileName = `${config.title.toLowerCase().replace(/[^a-z0-9]+/g, '-')}-${Date.now()}.html`;
|
||||
const filePath = path.join(this.outputDir, fileName);
|
||||
await fs.writeFile(filePath, html, 'utf-8');
|
||||
|
||||
const stat = await fs.stat(filePath);
|
||||
const docsPath = `reports/${fileName}`;
|
||||
|
||||
const report: GeneratedReport = {
|
||||
id: `report_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
title: config.title,
|
||||
template: config.template,
|
||||
htmlPath: filePath,
|
||||
docsPath,
|
||||
size: stat.size,
|
||||
generatedAt: new Date().toISOString(),
|
||||
brand,
|
||||
};
|
||||
|
||||
this.reports.push(report);
|
||||
await fs.writeFile(this.reportsPath, JSON.stringify(this.reports, null, 2));
|
||||
|
||||
log.info({ reportId: report.id, title: config.title, template: config.template }, 'Report generated');
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* List generated reports.
|
||||
*/
|
||||
async listReports(limit = 50): Promise<GeneratedReport[]> {
|
||||
await this.ensureLoaded();
|
||||
return this.reports
|
||||
.sort((a, b) => new Date(b.generatedAt).getTime() - new Date(a.generatedAt).getTime())
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific report.
|
||||
*/
|
||||
async getReport(id: string): Promise<GeneratedReport | null> {
|
||||
await this.ensureLoaded();
|
||||
return this.reports.find((r) => r.id === id) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available templates.
|
||||
*/
|
||||
getTemplates(): Array<{ id: ReportTemplate; name: string; description: string }> {
|
||||
return [
|
||||
{ id: 'audit', name: 'Audit Report', description: 'Security/code audit with findings and recommendations' },
|
||||
{ id: 'summary', name: 'Summary Report', description: 'Sprint/standup summary with key metrics' },
|
||||
{ id: 'analysis', name: 'Analysis Report', description: 'Comparison/research analysis with pros/cons' },
|
||||
{ id: 'standup', name: 'Standup Report', description: 'Daily standup with status updates per agent' },
|
||||
{ id: 'custom', name: 'Custom Report', description: 'Freeform markdown with brand styling' },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton
|
||||
let instance: PdfReportService | null = null;
|
||||
|
||||
export function getPdfReportService(): PdfReportService {
|
||||
if (!instance) {
|
||||
instance = new PdfReportService();
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue