fifteen/apps/web/app/api/location-score/route.ts
2026-03-01 21:58:53 +01:00

173 lines
6.6 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import type { CategoryId, ProfileId } from "@transportationer/shared";
import {
CATEGORY_IDS,
PROFILE_IDS,
} from "@transportationer/shared";
export const runtime = "nodejs";
const VALID_MODES = ["walking", "cycling", "driving"];
const VALID_THRESHOLDS = [5, 8, 10, 12, 15, 20, 25, 30];
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 (!VALID_MODES.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<CategoryId, number> = {
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<Record<CategoryId, number>> = {
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<Record<CategoryId, number>> = {
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,
};
// 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 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 = ${mode}
ORDER BY category, distance_m
`);
type SubcategoryDetail = {
subcategory: string;
name: string | null;
distanceM: number | null;
travelTimeS: number | null;
};
const subcategoryDetails: Partial<Record<CategoryId, SubcategoryDetail[]>> = {};
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,
});
}