import type { FastifyInstance, FastifyRequest, FastifyReply, FastifyBaseLogger } from 'fastify'; import type { Sql } from '../db.js'; import { generateReport, runReportSchedulerTick } from '../services/reports.js'; import { jsonbObject } from '../services/jsonb.js'; /** * P6.2: Reports tab API + scheduled digest. * * GET /api/reports — list generated reports (newest first) * GET /api/reports/:id — single report (markdown + stats) * POST /api/reports/generate — manually trigger a digest now * GET /api/reports/schedule — current schedule meta * POST /api/reports/schedule — update schedule meta {interval, enabled} */ export function registerReportRoutes(app: FastifyInstance, sql: Sql): void { app.get('/api/reports', async (_req: FastifyRequest, reply: FastifyReply) => { const rows = await sql<{ id: string; kind: string; interval: string; period_start: string; period_end: string; created_at: string; }[]>` SELECT id, kind, interval, period_start, period_end, created_at FROM control_reports ORDER BY created_at DESC LIMIT 100 `; return reply.send({ reports: rows.map((r) => ({ id: r.id, kind: r.kind, interval: r.interval, periodStart: r.period_start, periodEnd: r.period_end, createdAt: r.created_at, })), }); }); app.get('/api/reports/:id', async (req: FastifyRequest, reply: FastifyReply) => { const { id } = req.params as { id: string }; const rows = await sql<{ id: string; kind: string; interval: string; period_start: string; period_end: string; markdown: string; stats: unknown; created_at: string; }[]>` SELECT id, kind, interval, period_start, period_end, markdown, stats, created_at FROM control_reports WHERE id = ${id} `; if (rows.length === 0) { return reply.status(404).send({ error: 'report not found' }); } const r = rows[0]!; return reply.send({ id: r.id, kind: r.kind, interval: r.interval, periodStart: r.period_start, periodEnd: r.period_end, markdown: r.markdown, stats: jsonbObject(r.stats), createdAt: r.created_at, }); }); app.post('/api/reports/generate', async (req: FastifyRequest, reply: FastifyReply) => { const body = (req.body as Record) ?? {}; const interval = body.interval === 'weekly' ? 'weekly' : 'daily'; const id = await generateReport(sql, interval); return reply.status(201).send({ id }); }); app.get('/api/reports/schedule', async (_req: FastifyRequest, reply: FastifyReply) => { const rows = await sql<{ interval: string; enabled: boolean; last_run_at: string | null }[]>` SELECT interval, enabled, last_run_at FROM control_schedule_meta WHERE name = 'report-digest' `; const m = rows[0]; return reply.send({ interval: m?.interval ?? 'daily', enabled: m?.enabled ?? true, lastRunAt: m?.last_run_at ?? null, }); }); app.post('/api/reports/schedule', async (req: FastifyRequest, reply: FastifyReply) => { const body = (req.body as Record) ?? {}; const interval = body.interval === 'weekly' ? 'weekly' : 'daily'; const enabled = body.enabled !== false; await sql` UPDATE control_schedule_meta SET interval = ${interval}, enabled = ${enabled} WHERE name = 'report-digest' `; return reply.send({ interval, enabled }); }); } /** * Start the in-process report scheduler: an immediate catch-up tick on boot, * then hourly. Returns a stop function for onClose. */ export function startReportScheduler(sql: Sql, log: FastifyBaseLogger): () => void { const tick = async () => { try { const result = await runReportSchedulerTick(sql); if (result.ran) log.info({ reportId: result.reportId }, 'reports: digest generated'); } catch (err) { log.warn({ err: (err as Error).message }, 'reports: scheduler tick failed'); } }; // Catch-up on boot. void tick(); const timer = setInterval(tick, 3600_000); // hourly return () => clearInterval(timer); }