import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/lib/db"; import { cacheGet, cacheSet, hashParams } from "@/lib/cache"; import { compositeScore } from "@/lib/scoring"; import { CATEGORY_IDS, DEFAULT_WEIGHTS } from "@transportationer/shared"; import type { GridCell, HeatmapPayload, CategoryId } from "@transportationer/shared"; export const runtime = "nodejs"; const VALID_MODES = ["walking", "cycling", "driving"]; const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30]; export async function GET(req: NextRequest) { const p = req.nextUrl.searchParams; const city = p.get("city") ?? "berlin"; const mode = p.get("mode") ?? "walking"; const threshold = parseInt(p.get("threshold") ?? "15", 10); const bboxStr = p.get("bbox"); if (!VALID_MODES.includes(mode)) { return NextResponse.json( { error: "Invalid mode", code: "INVALID_MODE" }, { status: 400 }, ); } // Find closest pre-computed threshold const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) => Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev, ); // Parse weights let weights: Record; try { const w = p.get("weights"); weights = w ? JSON.parse(w) : DEFAULT_WEIGHTS; // Fill missing keys with defaults for (const cat of CATEGORY_IDS) { if (weights[cat] === undefined) weights[cat] = DEFAULT_WEIGHTS[cat]; } } catch { return NextResponse.json( { error: "Invalid weights JSON", code: "INVALID_WEIGHTS" }, { status: 400 }, ); } const cacheKey = `api:grid:${hashParams({ city, mode, threshold: closestThreshold, weights, bbox: bboxStr })}`; const cached = await cacheGet(cacheKey); if (cached) { return NextResponse.json(cached, { headers: { "X-Cache": "HIT", "Cache-Control": "public, s-maxage=60" }, }); } let bboxFilter = sql`TRUE`; if (bboxStr) { const parts = bboxStr.split(",").map(Number); if (parts.length === 4 && !parts.some(isNaN)) { const [minLng, minLat, maxLng, maxLat] = parts; bboxFilter = sql`ST_Within(gp.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))`; } } const rows = await Promise.resolve(sql<{ grid_x: number; grid_y: number; lng: number; lat: number; score_service_trade: number | null; score_transport: number | null; score_work_school: number | null; score_culture_community: number | null; score_recreation: number | null; }[]>` SELECT gp.grid_x, gp.grid_y, ST_X(gp.geom)::float AS lng, ST_Y(gp.geom)::float AS lat, MAX(gs.score) FILTER (WHERE gs.category = 'service_trade') AS score_service_trade, MAX(gs.score) FILTER (WHERE gs.category = 'transport') AS score_transport, MAX(gs.score) FILTER (WHERE gs.category = 'work_school') AS score_work_school, MAX(gs.score) FILTER (WHERE gs.category = 'culture_community') AS score_culture_community, MAX(gs.score) FILTER (WHERE gs.category = 'recreation') AS score_recreation FROM grid_points gp JOIN grid_scores gs ON gs.grid_point_id = gp.id WHERE gp.city_slug = ${city} AND gs.travel_mode = ${mode} AND gs.threshold_min = ${closestThreshold} AND ${bboxFilter} GROUP BY gp.grid_x, gp.grid_y, gp.geom ORDER BY gp.grid_y, gp.grid_x `); const cells: GridCell[] = rows.map((r) => { const categoryScores: Partial> = { service_trade: r.score_service_trade ?? 0, transport: r.score_transport ?? 0, work_school: r.score_work_school ?? 0, culture_community: r.score_culture_community ?? 0, recreation: r.score_recreation ?? 0, }; return { gridX: r.grid_x, gridY: r.grid_y, lng: r.lng, lat: r.lat, score: compositeScore(categoryScores, weights), categoryScores, }; }); const payload: HeatmapPayload = { citySlug: city, travelMode: mode as HeatmapPayload["travelMode"], thresholdMin: closestThreshold, weights, gridSpacingM: 200, cells, generatedAt: new Date().toISOString(), }; await cacheSet(cacheKey, payload, "API_GRID"); return NextResponse.json(payload, { headers: { "X-Cache": "MISS", "Cache-Control": "public, s-maxage=60" }, }); }