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; 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 }); }