112 lines
3.4 KiB
TypeScript
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 });
|
|
}
|