import type { FastifyInstance } from "fastify"; import type { Db } from "@parking/db"; import { requirePermission } from "../auth.js"; import { reportSummary, type Bucket } from "../reports.js"; // Admin reporting API. Read-only aggregation over the signed ledger (+ the sessions // cache for durations); no writes, no new event types. Gated on `report:read` — the // same permission the events feed/occupancy use. See reports.ts, wiki/concepts/reports.md. const BUCKETS: Bucket[] = ["hour", "day", "month"]; /** Clamp a query into a valid [from, to) + bucket. Defaults: last 30 days, daily. */ function parseQuery(q: { from?: string; to?: string; bucket?: string }): { from: string; to: string; bucket: Bucket; } { const now = Date.now(); const to = isFiniteIso(q.to) ? q.to! : new Date(now).toISOString(); const from = isFiniteIso(q.from) ? q.from! : new Date(now - 30 * 86_400_000).toISOString(); const bucket = BUCKETS.includes(q.bucket as Bucket) ? (q.bucket as Bucket) : "day"; // Guard the inversion (from after to) — swap rather than return an empty report. return from <= to ? { from, to, bucket } : { from: to, to: from, bucket }; } function isFiniteIso(s: string | undefined): boolean { return !!s && Number.isFinite(Date.parse(s)); } export async function reportRoutes(app: FastifyInstance, db: Db): Promise { const guard = requirePermission("report:read"); // The whole dashboard in one call: totals, the time series, peak-hour histogram, and // subscription stats — aggregated server-side so the SPA just renders. Bucketed in the // site timezone. See reports.ts. app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>( "/api/reports/summary", { preHandler: guard }, async (req) => reportSummary(db, parseQuery(req.query)), ); // The same series as CSV (one row per bucket) for spreadsheet / accountant export. // Amounts are in MAJOR units with 2 decimals here (a CSV is for humans/Excel), unlike // the JSON which stays in minor units. text/csv with a download filename. app.get<{ Querystring: { from?: string; to?: string; bucket?: string } }>( "/api/reports/summary.csv", { preHandler: guard }, async (req, reply) => { const summary = reportSummary(db, parseQuery(req.query)); const lines = [ "bucket,entries,exits,payments,revenue,cash,card,occupancy_end", ...summary.series.map((p) => [ p.bucket, p.entries, p.exits, p.payments, (p.revenueMinor / 100).toFixed(2), (p.cashMinor / 100).toFixed(2), (p.cardMinor / 100).toFixed(2), p.occupancyEnd, ].join(","), ), ]; reply .header("content-type", "text/csv; charset=utf-8") .header( "content-disposition", `attachment; filename="parking-report-${summary.from.slice(0, 10)}_${summary.to.slice(0, 10)}.csv"`, ) .send(lines.join("\n") + "\n"); }, ); }