diff --git a/server/src/routes/reports.ts b/server/src/routes/reports.ts new file mode 100644 index 00000000..16b7cf71 --- /dev/null +++ b/server/src/routes/reports.ts @@ -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 }; diff --git a/server/src/routes/v1/index.ts b/server/src/routes/v1/index.ts index 6f83c4d1..33ecd0db 100644 --- a/server/src/routes/v1/index.ts +++ b/server/src/routes/v1/index.ts @@ -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); diff --git a/server/src/services/pdf-report-service.ts b/server/src/services/pdf-report-service.ts new file mode 100644 index 00000000..4fd5edb0 --- /dev/null +++ b/server/src/services/pdf-report-service.ts @@ -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; + /** Include table of contents */ + includeToc?: boolean; + /** Include timestamp */ + includeTimestamp?: boolean; + /** Include page numbers */ + includePageNumbers?: boolean; + /** Author name */ + author?: string; + /** Additional metadata */ + metadata?: Record; +} + +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 = { + 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, '

$1

') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^# (.+)$/gm, '

$1

') + // Bold/italic + .replace(/\*\*\*(.+?)\*\*\*/g, '$1') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/\*(.+?)\*/g, '$1') + // Code blocks + .replace(/```[\w]*\n([\s\S]*?)```/g, '
$1
') + .replace(/`(.+?)`/g, '$1') + // Blockquotes + .replace(/^> (.+)$/gm, '
$1
') + // Horizontal rules + .replace(/^---$/gm, '
') + // Lists + .replace(/^- (.+)$/gm, '
  • $1
  • ') + .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') + // Links + .replace(/\[(.+?)\]\((.+?)\)/g, '$1') + // Paragraphs (lines not already wrapped) + .replace(/^(?!<[h1-6|li|pre|blockquote|hr|ul|ol|div])(.+)$/gm, '

    $1

    '); + + // Wrap consecutive li elements in ul + html = html.replace(/(
  • .*?<\/li>\n?)+/g, '
      $&
    '); + + 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 { + 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 { + await this.ensureLoaded(); + return { ...this.brandConfig }; + } + + /** + * Update brand config. + */ + async updateBrand(update: Partial): Promise { + 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 { + 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 + ? `
    Generated: ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
    ` + : ''; + + const authorLine = config.author ? `
    Author: ${config.author}
    ` : ''; + const subtitleLine = config.subtitle ? `
    ${config.subtitle}
    ` : ''; + + const logoHtml = brand.logoUrl + ? `` + : ''; + + const html = ` + + + + + ${config.title} — ${brand.companyName} + + + +
    + ${logoHtml} +

    ${config.title}

    + ${subtitleLine} + ${timestamp} + ${authorLine} +
    + +
    + ${contentHtml} +
    + + + +`; + + // 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 { + 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 { + 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; +}