import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/lib/db"; import { isochroneContours } from "@/lib/isochrone"; export const runtime = "nodejs"; const CACHE_TOLERANCE_M = 50; type CacheFeature = { geometry: object; properties: { contour: number } }; export async function GET(req: NextRequest) { console.log("[reachable-pois] GET called:", req.nextUrl.search); const { searchParams } = req.nextUrl; const city = searchParams.get("city"); const lat = parseFloat(searchParams.get("lat") ?? ""); const lng = parseFloat(searchParams.get("lng") ?? ""); const mode = searchParams.get("mode") ?? "walking"; const threshold = parseInt(searchParams.get("threshold") ?? "15", 10); if (!city || isNaN(lat) || isNaN(lng)) { return NextResponse.json({ error: "Missing required params", code: "INVALID_PARAMS" }, { status: 400 }); } // Map modes the same way the display isochrone route does. // "fifteen" uses cycling as the representative isochrone mode. // "transit" uses its own multimodal isochrone. const isoMode = mode === "fifteen" ? "cycling" : mode; // Use the identical contours array that the display isochrone route caches with, // so the exact-match lookup is guaranteed to find the entry once isochroneData // is set on the client (which is our fetch trigger). const contours = isochroneContours(threshold); const cached = await Promise.resolve(sql<{ result: { features: CacheFeature[] } }[]>` SELECT result FROM isochrone_cache WHERE travel_mode = ${isoMode} AND contours_min = ${contours} 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) { // Should not happen if client fires this only after isochroneData is set. console.warn(`[reachable-pois] cache miss for ${city} ${lat},${lng} mode=${isoMode} contours=${JSON.stringify(contours)}`); return NextResponse.json({ pois: [], _debug: "cache_miss" }, { headers: { "Cache-Control": "no-store" } }); } // Existing cache entries may have been stored with JSON.stringify() (double-encoded) // resulting in a JSONB string instead of a JSONB object. Parse those on read. const rawResult = cached[0].result; const result: { features?: CacheFeature[] } = typeof rawResult === "string" ? JSON.parse(rawResult as string) : rawResult; const features = result?.features; if (!Array.isArray(features) || features.length === 0) { console.warn(`[reachable-pois] bad features: ${JSON.stringify(rawResult).slice(0, 200)}`); return NextResponse.json({ pois: [], _debug: "bad_features" }, { headers: { "Cache-Control": "no-store" } }); } // Pick the feature matching the exact threshold contour, or fall back to the largest. const contourValues = features.map((f) => f.properties?.contour); const match = features.find((f) => f.properties?.contour === threshold); const polygon = (match ?? features[features.length - 1])?.geometry; console.log(`[reachable-pois] features=${features.length} contours=${JSON.stringify(contourValues)} match=${!!match} threshold=${threshold}`); if (!polygon) { console.warn(`[reachable-pois] no polygon geometry`); return NextResponse.json({ pois: [], _debug: "no_polygon" }, { headers: { "Cache-Control": "no-store" } }); } const pois = await Promise.resolve(sql<{ category: string; subcategory: string; name: string | null; lat: number; lng: number }[]>` SELECT category, subcategory, name, ST_Y(geom)::float AS lat, ST_X(geom)::float AS lng FROM raw_pois WHERE city_slug = ${city} AND ST_Intersects(geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326)) ORDER BY category, subcategory, name `); if (pois.length === 0) { // Log to help distinguish "no POIs in polygon" from a query error. console.warn(`[reachable-pois] 0 pois for ${city} (cache hit, polygon type=${(polygon as { type?: string }).type})`); return NextResponse.json({ pois: [], _debug: "empty_spatial" }, { headers: { "Cache-Control": "no-store" } }); } return NextResponse.json( { pois }, { headers: { "Cache-Control": "public, max-age=3600" } }, ); }