fifteen/apps/web/app/api/admin/cities/route.ts

181 lines
5.5 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, boundary } = 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 boundary GeoJSON (Polygon or MultiPolygon)
if (boundary !== undefined) {
const b = boundary as Record<string, unknown>;
if (b.type !== "Polygon" && b.type !== "MultiPolygon") {
return NextResponse.json(
{ error: "boundary must be a GeoJSON Polygon or MultiPolygon" },
{ status: 400 },
);
}
}
// Validate optional bbox [minLng, minLat, maxLng, maxLat]
let validBbox: [number, number, number, number] | null = null;
if (bbox !== undefined && boundary === 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 (boundary !== undefined) {
const boundaryJson = JSON.stringify(boundary);
await Promise.resolve(sql`
INSERT INTO cities (slug, name, country_code, geofabrik_url, bbox, boundary, status)
VALUES (
${slug as string},
${(name as string) ?? slug},
${(countryCode as string) ?? ""},
${geofabrikUrl},
ST_Envelope(ST_GeomFromGeoJSON(${boundaryJson})),
ST_Multi(ST_SetSRID(ST_GeomFromGeoJSON(${boundaryJson}), 4326)),
'pending'
)
ON CONFLICT (slug) DO UPDATE
SET status = 'pending',
geofabrik_url = EXCLUDED.geofabrik_url,
bbox = EXCLUDED.bbox,
boundary = EXCLUDED.boundary,
error_message = NULL
`);
} else 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 [{ iter }] = await Promise.resolve(sql<{ iter: number }[]>`
UPDATE cities SET refresh_iter = refresh_iter + 1 WHERE slug = ${slug as string}
RETURNING refresh_iter AS iter
`);
const queue = getPipelineQueue();
const job = await queue.add(
"refresh-city",
{
type: "refresh-city",
citySlug: slug as string,
geofabrikUrl,
resolutionM: resolutionM as number,
iter,
},
{ ...JOB_OPTIONS["refresh-city"], jobId: `refresh-city.${slug}.${iter}` },
);
// Invalidate city list cache
await cacheDel("api:cities:*");
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });
}