fifteen/apps/web/app/api/isochrones/route.ts

112 lines
3.4 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { fetchIsochrone } from "@/lib/valhalla";
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, city } = 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 },
);
}
if (typeof city !== "string" || !city) {
return NextResponse.json(
{ error: "city is required", code: "MISSING_CITY" },
{ 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 });
}
// Dispatch to valhalla routing queue via BullMQ.
let geojson: object;
try {
geojson = await fetchIsochrone({
lng,
lat,
travelMode: mode,
contourMinutes: contours,
citySlug: city,
});
} catch (err) {
const msg = err instanceof Error ? err.message : "unknown";
// Distinguish "tiles not built yet" from other errors
if (msg.includes("not ready") || msg.includes("timed out") || msg.includes("timeout")) {
return NextResponse.json(
{ error: "Routing engine unavailable — tiles may still be building.", code: "VALHALLA_UNAVAILABLE", detail: msg },
{ status: 503 },
);
}
return NextResponse.json(
{ error: "Routing engine error", code: "VALHALLA_ERROR", detail: msg },
{ status: 503 },
);
}
// Validate result before caching (Actor may return an error object)
const fc = geojson as { features?: unknown[]; error?: unknown; error_code?: unknown };
if (fc.error || fc.error_code || !Array.isArray(fc.features)) {
return NextResponse.json(
{ error: "Routing engine returned invalid response", code: "VALHALLA_BAD_RESPONSE" },
{ status: 503 },
);
}
// Store in PostGIS cache.
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 });
}