fifteen/apps/web/app/api/pois/route.ts
2026-03-01 21:59:44 +01:00

112 lines
3.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { cacheGet, cacheSet, hashParams } from "@/lib/cache";
import { CATEGORY_IDS } from "@transportationer/shared";
import type { Poi } from "@transportationer/shared";
export const runtime = "nodejs";
export async function GET(req: NextRequest) {
const p = req.nextUrl.searchParams;
const city = p.get("city") ?? "berlin";
const category = p.get("category");
const bboxStr = p.get("bbox");
const cluster = p.get("cluster") === "true";
const zoom = parseInt(p.get("zoom") ?? "12", 10);
if (category && !CATEGORY_IDS.includes(category as any)) {
return NextResponse.json(
{ error: "Invalid category", code: "INVALID_CATEGORY" },
{ status: 400 },
);
}
const cacheKey = `api:pois:${hashParams({ city, category, bbox: bboxStr, cluster, zoom })}`;
const cached = await cacheGet<Poi[]>(cacheKey);
if (cached) {
return NextResponse.json(cached, { headers: { "X-Cache": "HIT" } });
}
let bboxFilter = sql`TRUE`;
if (bboxStr) {
const parts = bboxStr.split(",").map(Number);
if (parts.length !== 4 || parts.some(isNaN)) {
return NextResponse.json(
{ error: "Invalid bbox", code: "INVALID_BBOX" },
{ status: 400 },
);
}
const [minLng, minLat, maxLng, maxLat] = parts;
bboxFilter = sql`ST_Within(p.geom, ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326))`;
}
let catFilter = sql`TRUE`;
if (category) {
catFilter = sql`p.category = ${category}`;
}
if (cluster && zoom < 14) {
const rows = await Promise.resolve(sql<{
count: number;
lng: number;
lat: number;
category: string;
}[]>`
SELECT
COUNT(*)::int AS count,
ST_X(ST_Centroid(ST_Collect(p.geom)))::float AS lng,
ST_Y(ST_Centroid(ST_Collect(p.geom)))::float AS lat,
p.category
FROM raw_pois p
WHERE p.city_slug = ${city}
AND ${catFilter}
AND ${bboxFilter}
GROUP BY
ROUND(ST_X(p.geom)::numeric, 2),
ROUND(ST_Y(p.geom)::numeric, 2),
p.category
LIMIT 2000
`);
await cacheSet(cacheKey, rows, "API_POIS");
return NextResponse.json(rows);
}
const rows = await Promise.resolve(sql<{
osm_id: string;
osm_type: string;
category: string;
subcategory: string;
name: string | null;
lng: number;
lat: number;
}[]>`
SELECT
p.osm_id::text,
p.osm_type,
p.category,
p.subcategory,
p.name,
ST_X(p.geom)::float AS lng,
ST_Y(p.geom)::float AS lat
FROM raw_pois p
WHERE p.city_slug = ${city}
AND ${catFilter}
AND ${bboxFilter}
LIMIT 5000
`);
const pois: Poi[] = rows.map((r) => ({
osmId: r.osm_id,
osmType: r.osm_type as "N" | "W" | "R",
category: r.category as Poi["category"],
subcategory: r.subcategory,
name: r.name,
lng: r.lng,
lat: r.lat,
}));
await cacheSet(cacheKey, pois, "API_POIS");
return NextResponse.json(pois, {
headers: { "Cache-Control": "public, s-maxage=300" },
});
}