import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/lib/db"; import { fetchIsochrone } from "@/lib/valhalla"; import { getValhallaQueue, getValhallaTransitQueue } from "@/lib/queue"; 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 } = body as Record< string, unknown >; if (typeof lng !== "number" || typeof lat !== "number") { return NextResponse.json( { error: "lng and lat must be numbers", code: "INVALID_COORDS" }, { 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 }); } // Refuse to call valhalla_service while tiles are being rebuilt — // the service is stopped during the build and requests would hang or fail. // Check the queue that owns the requested mode's instance. const rebuildQueue = mode === "transit" ? getValhallaTransitQueue() : getValhallaQueue(); const activeValhalla = await rebuildQueue.getActiveCount(); if (activeValhalla > 0) { return NextResponse.json( { error: "Routing engine is rebuilding, please try again shortly.", code: "VALHALLA_REBUILDING" }, { status: 503, headers: { "Retry-After": "60" } }, ); } // Fetch from local Valhalla let geojson: object; try { geojson = await fetchIsochrone({ lng, lat, travelMode: mode, contourMinutes: contours, polygons: true, }); } catch (err) { return NextResponse.json( { error: "Routing engine unavailable", code: "VALHALLA_ERROR", detail: err instanceof Error ? err.message : "unknown", }, { status: 503 }, ); } // Store in PostGIS cache. // Use an explicit ::jsonb cast so PostgreSQL receives a text parameter and // parses it as JSON itself. Without the cast, postgres.js infers the JSONB // column type and re-encodes the string as a JSONB string literal. 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 }); }