import { NextRequest, NextResponse } from "next/server"; import { sql } from "@/lib/db"; import type { CategoryId, ProfileId } from "@transportationer/shared"; import { CATEGORY_IDS, PROFILE_IDS, TRAVEL_MODES, VALID_THRESHOLDS, } from "@transportationer/shared"; export const runtime = "nodejs"; export async function GET(req: NextRequest) { const p = req.nextUrl.searchParams; const lat = parseFloat(p.get("lat") ?? ""); const lng = parseFloat(p.get("lng") ?? ""); const city = p.get("city") ?? ""; const mode = p.get("mode") ?? "walking"; const threshold = parseInt(p.get("threshold") ?? "15", 10); const profileId = (PROFILE_IDS as readonly string[]).includes(p.get("profile") ?? "") ? (p.get("profile") as ProfileId) : "universal" as ProfileId; if (isNaN(lat) || isNaN(lng)) { return NextResponse.json({ error: "Invalid lat/lng" }, { status: 400 }); } if (!city) return NextResponse.json({ error: "Missing city" }, { status: 400 }); if (!(TRAVEL_MODES as readonly string[]).includes(mode)) { return NextResponse.json({ error: "Invalid mode" }, { status: 400 }); } const closestThreshold = VALID_THRESHOLDS.reduce((prev, curr) => Math.abs(curr - threshold) < Math.abs(prev - threshold) ? curr : prev, ); const rows = await Promise.resolve(sql<{ grid_point_id: string; grid_lat: number; grid_lng: number; score_service_trade: number | null; score_transport: number | null; score_work_school: number | null; score_culture_community: number | null; score_recreation: number | null; dist_service_trade: number | null; dist_transport: number | null; dist_work_school: number | null; dist_culture_community: number | null; dist_recreation: number | null; time_service_trade: number | null; time_transport: number | null; time_work_school: number | null; time_culture_community: number | null; time_recreation: number | null; }[]>` WITH nearest AS ( SELECT gp.id, gp.id::text AS grid_point_id, ST_Y(gp.geom)::float AS grid_lat, ST_X(gp.geom)::float AS grid_lng FROM grid_points gp WHERE gp.city_slug = ${city} ORDER BY gp.geom <-> ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326) LIMIT 1 ) SELECT n.grid_point_id, n.grid_lat, n.grid_lng, MAX(gs.score) FILTER (WHERE gs.category = 'service_trade') AS score_service_trade, MAX(gs.score) FILTER (WHERE gs.category = 'transport') AS score_transport, MAX(gs.score) FILTER (WHERE gs.category = 'work_school') AS score_work_school, MAX(gs.score) FILTER (WHERE gs.category = 'culture_community') AS score_culture_community, MAX(gs.score) FILTER (WHERE gs.category = 'recreation') AS score_recreation, MAX(gs.distance_m) FILTER (WHERE gs.category = 'service_trade') AS dist_service_trade, MAX(gs.distance_m) FILTER (WHERE gs.category = 'transport') AS dist_transport, MAX(gs.distance_m) FILTER (WHERE gs.category = 'work_school') AS dist_work_school, MAX(gs.distance_m) FILTER (WHERE gs.category = 'culture_community') AS dist_culture_community, MAX(gs.distance_m) FILTER (WHERE gs.category = 'recreation') AS dist_recreation, MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'service_trade') AS time_service_trade, MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'transport') AS time_transport, MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'work_school') AS time_work_school, MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'culture_community') AS time_culture_community, MAX(gs.travel_time_s) FILTER (WHERE gs.category = 'recreation') AS time_recreation FROM nearest n JOIN grid_scores gs ON gs.grid_point_id = n.id WHERE gs.travel_mode = ${mode} AND gs.threshold_min = ${closestThreshold} AND gs.profile = ${profileId} GROUP BY n.grid_point_id, n.grid_lat, n.grid_lng `); if (rows.length === 0) { return NextResponse.json( { error: "No grid data for this location" }, { status: 404 }, ); } const r = rows[0]; const categoryScores: Record = { service_trade: r.score_service_trade ?? 0, transport: r.score_transport ?? 0, work_school: r.score_work_school ?? 0, culture_community: r.score_culture_community ?? 0, recreation: r.score_recreation ?? 0, }; const distancesM: Partial> = { service_trade: r.dist_service_trade ?? undefined, transport: r.dist_transport ?? undefined, work_school: r.dist_work_school ?? undefined, culture_community: r.dist_culture_community ?? undefined, recreation: r.dist_recreation ?? undefined, }; const travelTimesS: Partial> = { service_trade: r.time_service_trade ?? undefined, transport: r.time_transport ?? undefined, work_school: r.time_work_school ?? undefined, culture_community: r.time_culture_community ?? undefined, recreation: r.time_recreation ?? undefined, }; // "fifteen" is synthetic — grid_poi_details only stores the raw modes // (walking, cycling, transit, driving). For fifteen we query all three // contributing modes and pick the best (minimum travel_time_s) per subcategory. const detailModes = mode === "fifteen" ? ["walking", "cycling", "transit"] : [mode]; // Fetch nearest POI per subcategory for this grid point and mode. const detailRows = await Promise.resolve(sql<{ category: string; subcategory: string; nearest_poi_name: string | null; distance_m: number | null; travel_time_s: number | null; }[]>` SELECT DISTINCT ON (category, subcategory) category, subcategory, nearest_poi_name, distance_m, travel_time_s FROM grid_poi_details WHERE grid_point_id = ${r.grid_point_id}::bigint AND travel_mode = ANY(${detailModes}) ORDER BY category, subcategory, travel_time_s ASC NULLS LAST `); type SubcategoryDetail = { subcategory: string; name: string | null; distanceM: number | null; travelTimeS: number | null; }; const subcategoryDetails: Partial> = {}; for (const cat of CATEGORY_IDS) { subcategoryDetails[cat] = []; } for (const row of detailRows) { const cat = row.category as CategoryId; if (subcategoryDetails[cat]) { subcategoryDetails[cat]!.push({ subcategory: row.subcategory, name: row.nearest_poi_name, distanceM: row.distance_m, travelTimeS: row.travel_time_s, }); } } return NextResponse.json({ lat: r.grid_lat, lng: r.grid_lng, categoryScores, distancesM, travelTimesS, subcategoryDetails, profile: profileId, }); }