fifteen/apps/web/app/api/reachable-pois/route.ts

98 lines
4.3 KiB
TypeScript

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 });
}
// Synthetic modes map to a single isochrone mode for the spatial POI lookup.
// "cyclist" uses cycling (largest catchment); "transit_walk" uses transit.
const isoMode =
mode === "cyclist" ? "cycling" :
mode === "cycling_walk" ? "cycling" :
mode === "transit_walk" ? "transit" :
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" } },
);
}