import { NextRequest, NextResponse } from 'next/server'; import { withAuth, withPermission } from '@/lib/api/helpers'; import { ALL_RANGES, getLeadSourceAttribution, getOccupancyTimeline, getPipelineFunnel, getRevenueBreakdown, type DateRange, type MetricBase, type PresetDateRange, } from '@/lib/services/analytics.service'; const METRICS: Record Promise> = { pipeline_funnel: getPipelineFunnel, occupancy_timeline: getOccupancyTimeline, revenue_breakdown: getRevenueBreakdown, lead_source_attribution: getLeadSourceAttribution, }; const ISO_DATE_RX = /^\d{4}-\d{2}-\d{2}$/; export const GET = withAuth( withPermission('reports', 'view_analytics', async (req: NextRequest, ctx) => { const url = new URL(req.url); const metric = url.searchParams.get('metric') as MetricBase | null; const rawRange = url.searchParams.get('range') ?? '30d'; const fromParam = url.searchParams.get('from'); const toParam = url.searchParams.get('to'); if (!metric || !(metric in METRICS)) { return NextResponse.json({ error: 'Invalid or missing metric' }, { status: 400 }); } let range: DateRange; if (rawRange === 'custom') { if (!fromParam || !toParam) { return NextResponse.json( { error: 'Custom range requires `from` and `to` (YYYY-MM-DD)' }, { status: 400 }, ); } if (!ISO_DATE_RX.test(fromParam) || !ISO_DATE_RX.test(toParam)) { return NextResponse.json( { error: '`from`/`to` must be ISO date strings (YYYY-MM-DD)' }, { status: 400 }, ); } if (fromParam > toParam) { return NextResponse.json({ error: '`from` must be on or before `to`' }, { status: 400 }); } // Round-trip date check: regex passes "9999-13-99" or "2026-02-31" // (rolls over silently when handed to `new Date`). Re-serialize and // confirm it matches the input to catch invalid calendar values. for (const [label, raw] of [ ['from', fromParam], ['to', toParam], ] as const) { const d = new Date(`${raw}T00:00:00.000Z`); if (Number.isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== raw) { return NextResponse.json( { error: `\`${label}\` is not a valid calendar date` }, { status: 400 }, ); } } // Backstop against the occupancy-timeline N+1 query loop. Each day // in the range issues its own DB query, so a multi-year custom // range would saturate the connection pool. 365 days is a generous // ceiling for analytical queries; if a longer span is needed, the // service should be restructured to use `generate_series` instead // of a JS loop. const fromMs = new Date(`${fromParam}T00:00:00.000Z`).getTime(); const toMs = new Date(`${toParam}T23:59:59.999Z`).getTime(); if ((toMs - fromMs) / 86_400_000 > 365) { return NextResponse.json({ error: 'Custom range cannot exceed 365 days' }, { status: 400 }); } range = { kind: 'custom', from: fromParam, to: toParam }; } else { if (!ALL_RANGES.includes(rawRange as PresetDateRange)) { return NextResponse.json({ error: 'Invalid range' }, { status: 400 }); } range = rawRange as PresetDateRange; } const data = await METRICS[metric](ctx.portId, range); return NextResponse.json({ metric, range, data }); }), );