175 lines
5.3 KiB
TypeScript
175 lines
5.3 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 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"], jobId: `refresh-city.${slug}` },
|
|
);
|
|
|
|
// Invalidate city list cache
|
|
await cacheDel("api:cities:*");
|
|
|
|
return NextResponse.json({ citySlug: slug, jobId: job.id }, { status: 202 });
|
|
}
|