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

129 lines
5.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 } };
// Which raw travel modes feed into each synthetic persona mode for scoring.
const DETAIL_MODES: Record<string, string[]> = {
cyclist: ["walking", "cycling", "transit"],
cycling_walk: ["walking", "cycling"],
transit_walk: ["walking", "transit"],
};
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;
// Raw travel modes that feed into this mode's scoring (for scored POI lookup).
const detailModes = DETAIL_MODES[mode] ?? [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; scored: boolean;
}[]>`
WITH nearest_gp AS (
SELECT id AS grid_point_id
FROM grid_points
WHERE city_slug = ${city}
ORDER BY geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326)
LIMIT 1
),
scored_ids AS (
SELECT DISTINCT gpd.nearest_poi_id
FROM grid_poi_details gpd
CROSS JOIN nearest_gp ng
WHERE gpd.grid_point_id = ng.grid_point_id
AND gpd.travel_mode = ANY(${detailModes})
AND gpd.nearest_poi_id IS NOT NULL
)
SELECT
p.category, p.subcategory, p.name,
ST_Y(p.geom)::float AS lat, ST_X(p.geom)::float AS lng,
(s.nearest_poi_id IS NOT NULL) AS scored
FROM raw_pois p
LEFT JOIN scored_ids s ON p.osm_id = s.nearest_poi_id
WHERE p.city_slug = ${city}
AND ST_Intersects(p.geom, ST_SetSRID(ST_GeomFromGeoJSON(${JSON.stringify(polygon)}), 4326))
ORDER BY p.category, p.subcategory, p.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" } },
);
}