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

144 lines
4.1 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { sql } from "@/lib/db";
import { getPipelineQueue, JOB_OPTIONS } from "@/lib/queue";
import { cacheDel } from "@/lib/cache";
export const runtime = "nodejs";
const GEOFABRIK_URL_PATTERN =
/^https:\/\/download\.geofabrik\.de\/[\w][\w/-]+-latest\.osm\.pbf$/;
export async function GET() {
const rows = await Promise.resolve(sql<{
slug: string;
name: string;
country_code: string;
geofabrik_url: string;
status: string;
last_ingested: string | null;
poi_count: number;
grid_count: number;
error_message: string | null;
}[]>`
SELECT
c.slug,
c.name,
c.country_code,
c.geofabrik_url,
c.status,
c.last_ingested,
c.error_message,
COALESCE(p.poi_count, 0)::int AS poi_count,
COALESCE(g.grid_count, 0)::int AS grid_count
FROM cities c
LEFT JOIN (
SELECT city_slug, COUNT(*) AS poi_count FROM raw_pois GROUP BY city_slug
) p ON p.city_slug = c.slug
LEFT JOIN (
SELECT city_slug, COUNT(*) AS grid_count FROM grid_points GROUP BY city_slug
) g ON g.city_slug = c.slug
ORDER BY c.name
`);
return NextResponse.json(rows);
}
export async function POST(req: NextRequest) {
let body: Record<string, unknown>;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const { name, slug, countryCode, geofabrikUrl, resolutionM = 200, bbox } = body;
if (typeof slug !== "string" || !/^[a-z0-9-]+$/.test(slug)) {
return NextResponse.json(
{ error: "slug must be lowercase alphanumeric with hyphens" },
{ status: 400 },
);
}
if (typeof geofabrikUrl !== "string" || !GEOFABRIK_URL_PATTERN.test(geofabrikUrl)) {
return NextResponse.json(
{ error: "geofabrikUrl must be a valid Geofabrik PBF URL" },
{ status: 400 },
);
}
// Validate optional bbox [minLng, minLat, maxLng, maxLat]
let validBbox: [number, number, number, number] | null = null;
if (bbox !== undefined) {
if (
!Array.isArray(bbox) ||
bbox.length !== 4 ||
!bbox.every((v: unknown) => typeof v === "number" && isFinite(v))
) {
return NextResponse.json(
{ error: "bbox must be [minLng, minLat, maxLng, maxLat] with numeric values" },
{ status: 400 },
);
}
const [minLng, minLat, maxLng, maxLat] = bbox as [number, number, number, number];
if (minLng >= maxLng || minLat >= maxLat) {
return NextResponse.json(
{ error: "bbox: minLng must be < maxLng and minLat must be < maxLat" },
{ status: 400 },
);
}
validBbox = [minLng, minLat, maxLng, maxLat];
}
if (validBbox) {
const [minLng, minLat, maxLng, maxLat] = validBbox;
await Promise.resolve(sql`
INSERT INTO cities (slug, name, country_code, geofabrik_url, bbox, status)
VALUES (
${slug as string},
${(name as string) ?? slug},
${(countryCode as string) ?? ""},
${geofabrikUrl},
ST_MakeEnvelope(${minLng}, ${minLat}, ${maxLng}, ${maxLat}, 4326),
'pending'
)
ON CONFLICT (slug) DO UPDATE
SET status = 'pending',
geofabrik_url = EXCLUDED.geofabrik_url,
bbox = EXCLUDED.bbox,
error_message = NULL
`);
} else {
await Promise.resolve(sql`
INSERT INTO cities (slug, name, country_code, geofabrik_url, status)
VALUES (
${slug as string},
${(name as string) ?? slug},
${(countryCode as string) ?? ""},
${geofabrikUrl},
'pending'
)
ON CONFLICT (slug) DO UPDATE
SET status = 'pending',
geofabrik_url = EXCLUDED.geofabrik_url,
error_message = NULL
`);
}
const queue = getPipelineQueue();
const job = await queue.add(
"refresh-city",
{
type: "refresh-city",
citySlug: slug as string,
geofabrikUrl,
resolutionM: resolutionM as number,
},
JOB_OPTIONS["refresh-city"],
);
// Invalidate city list cache
await cacheDel("api:cities:*");
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });
}