import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/lib/db"; import { fetchIsochrone } from "@/lib/valhalla"; import { nextTuesdayDeparture } from "@transportationer/shared"; export const runtime = "nodejs"; const CACHE_TOLERANCE_M = 50; export async function POST(req: NextRequest) { let body: unknown; try { body = await req.json(); } catch { return NextResponse.json( { error: "Invalid JSON body", code: "INVALID_BODY" }, { status: 400 }, ); } const { lng, lat, travelMode, contourMinutes, city } = body as Record; if (typeof lng !== "number" || typeof lat !== "number") { return NextResponse.json( { error: "lng and lat must be numbers", code: "INVALID_COORDS" }, { status: 400 }, ); } if (typeof city !== "string" || !city) { return NextResponse.json( { error: "city is required", code: "MISSING_CITY" }, { status: 400 }, ); } const contours: number[] = Array.isArray(contourMinutes) ? (contourMinutes as number[]) : [5, 10, 15]; const mode = typeof travelMode === "string" ? travelMode : "walking"; const departureDate = mode === "transit" ? nextTuesdayDeparture() : null; // Check PostGIS isochrone cache const cached = await Promise.resolve(sql<{ result: object }[]>` SELECT result FROM isochrone_cache WHERE travel_mode = ${mode} AND contours_min = ${contours} AND departure_date IS NOT DISTINCT FROM ${departureDate} AND ST_DWithin( origin_geom::geography, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)::geography, ${CACHE_TOLERANCE_M} ) ORDER BY created_at DESC LIMIT 1 `); if (cached.length > 0) { const raw = cached[0].result; const result = typeof raw === "string" ? JSON.parse(raw as string) : raw; return NextResponse.json({ ...result, cached: true }); } // Dispatch to valhalla routing queue via BullMQ. let geojson: object; try { geojson = await fetchIsochrone({ lng, lat, travelMode: mode, contourMinutes: contours, citySlug: city, }); } catch (err) { const msg = err instanceof Error ? err.message : "unknown"; // Distinguish "tiles not built yet" from other errors if (msg.includes("not ready") || msg.includes("timed out") || msg.includes("timeout")) { return NextResponse.json( { error: "Routing engine unavailable — tiles may still be building.", code: "VALHALLA_UNAVAILABLE", detail: msg }, { status: 503 }, ); } return NextResponse.json( { error: "Routing engine error", code: "VALHALLA_ERROR", detail: msg }, { status: 503 }, ); } // Validate result before caching (Actor may return an error object) const fc = geojson as { features?: unknown[]; error?: unknown; error_code?: unknown }; if (fc.error || fc.error_code || !Array.isArray(fc.features)) { return NextResponse.json( { error: "Routing engine returned invalid response", code: "VALHALLA_BAD_RESPONSE" }, { status: 503 }, ); } // Store in PostGIS cache. await Promise.resolve(sql` INSERT INTO isochrone_cache (origin_geom, travel_mode, contours_min, departure_date, result) VALUES ( ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326), ${mode}, ${contours}, ${departureDate}, ${JSON.stringify(geojson)}::jsonb ) `); return NextResponse.json({ ...geojson, cached: false }); }