feat(industrial): OSM bulk import + bbox map + admin review (PR 2-4/4)
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running

Pulls every `landuse=industrial` feature from OpenStreetMap into the
IndustrialPark catalog and surfaces it on the public KCN map. Admins can
promote raw OSM rows into the public catalog or lock individual fields
to protect them from the monthly reconciliation sync.

PR 2 — Bulk import script (scripts/sync-osm-industrial-parks.ts):
  • Splits Vietnam into 4 chunks (north / northCentral / southCentral /
    south) to stay under Overpass 504 timeouts.
  • Posts to overpass-api.de with form-encoded body, converts via
    osmtogeojson, derives centroid + area via @turf/centroid + @turf/area.
  • Upsert keyed on osmId. Honours `osmLocked` (skip row entirely) and
    `lockedFields[]` (skip individual columns) so admin edits survive.
  • Inserts use $executeRawUnsafe with ST_SetSRID(ST_MakePoint, 4326)
    because Prisma can't manage the Unsupported geometry NOT NULL column.
  • CLI flags: --dry-run, --chunk=NAME.

PR 3 — Bbox spatial API + Mapbox layer:
  • GET /industrial/parks/by-bbox returns a GeoJSON FeatureCollection
    filtered by ST_MakeEnvelope. Sends Point-only at zoom < 12,
    MultiPolygon outline at zoom >= 12 to keep payloads light.
  • Public consumers see MANUAL + OSM_PROMOTED only; admins can pass
    includeOsmRaw=true to also see raw OSM imports.
  • OsmParkBboxMap component drives Mapbox from viewport moveend with
    AbortController-debounced fetches, clusters at zoom < 12, expands
    via getClusterExpansionZoom (callback-style API).
  • /khu-cong-nghiep page now uses the bbox map in map + split views.

PR 4 — Admin review queue + monthly cron:
  • Commands: PromoteOsmPark (OSM → OSM_PROMOTED + isPublic=true,
    optional lockFields), LockOsmPark (toggle row-level skip flag).
  • Query: ListOsmPending lists rows with dataSource='OSM' for review.
  • OsmSyncCronService runs `0 2 1 * *` Asia/Ho_Chi_Minh and spawns
    sync-osm-industrial-parks.ts per chunk. Skipped unless
    OSM_SYNC_ENABLED=true so dev never accidentally hits Overpass.
  • New admin page /admin/industrial/osm-review: searchable table,
    promote dialog with quick-pick lock fields (name, developer,
    description, etc.) plus a free-text fallback, lock/unlock toggle,
    deep-link to openstreetmap.org for verification.

Repository changes:
  • PrismaIndustrialParkRepository now filters public queries to
    `isPublic = true AND dataSource IN (MANUAL, OSM_PROMOTED)` so raw
    OSM rows stay hidden from end users.
  • Added *.rdb to .gitignore (Redis dump local artefact).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-04-29 19:22:32 +07:00
parent 99f305f6ba
commit b3143991ce
22 changed files with 2179 additions and 8 deletions

View File

@@ -0,0 +1,288 @@
'use client';
/* eslint-disable import-x/no-named-as-default-member */
import mapboxgl from 'mapbox-gl';
import * as React from 'react';
import 'mapbox-gl/dist/mapbox-gl.css';
import { useMapboxStyle } from '@/lib/mapbox-style';
const VN_CENTER: [number, number] = [106.0, 16.0];
const DEFAULT_ZOOM = 5;
const SOURCE_ID = 'osm-parks';
const CLUSTER_LAYER_ID = 'osm-parks-clusters';
const CLUSTER_COUNT_LAYER_ID = 'osm-parks-cluster-count';
const POINT_LAYER_ID = 'osm-parks-points';
const BOUNDARY_FILL_LAYER_ID = 'osm-parks-boundaries-fill';
const BOUNDARY_LINE_LAYER_ID = 'osm-parks-boundaries-line';
interface OsmParkBboxMapProps {
className?: string;
/** Override the bbox API path. Default = `${NEXT_PUBLIC_API_URL}/industrial/parks/by-bbox`. */
apiPath?: string;
/** Show raw OSM-imported parks (admin tools). Default false. */
includeOsmRaw?: boolean;
}
/**
* Viewport-driven KCN map. Pulls parks from the bbox endpoint as the user
* pans/zooms — clusters at low zoom (<12), shows polygon outlines at
* high zoom. Designed for the public catalog where we have ~2000 OSM
* imports + 50 curated rows; loading the entire dataset eagerly would
* be wasteful.
*/
export function OsmParkBboxMap({
className,
apiPath,
includeOsmRaw = false,
}: OsmParkBboxMapProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const mapRef = React.useRef<mapboxgl.Map | null>(null);
const fetchAbortRef = React.useRef<AbortController | null>(null);
const mapStyle = useMapboxStyle();
const apiBase = React.useMemo(() => {
if (apiPath) return apiPath;
const apiUrl = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3201/api/v1';
return `${apiUrl}/industrial/parks/by-bbox`;
}, [apiPath]);
// Capture the current includeOsmRaw value via a ref so the moveend
// handler always sees the latest without re-binding the listener.
const includeOsmRawRef = React.useRef(includeOsmRaw);
React.useEffect(() => {
includeOsmRawRef.current = includeOsmRaw;
}, [includeOsmRaw]);
React.useEffect(() => {
if (!containerRef.current) return;
const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
if (!token) return;
mapboxgl.accessToken = token;
const map = new mapboxgl.Map({
container: containerRef.current,
style: mapStyle,
center: VN_CENTER,
zoom: DEFAULT_ZOOM,
attributionControl: false,
});
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
map.addControl(
new mapboxgl.AttributionControl({ compact: true, customAttribution: 'Data © OSM' }),
'bottom-right',
);
mapRef.current = map;
const fetchParks = async () => {
try {
// Cancel any in-flight request — only the latest viewport matters.
fetchAbortRef.current?.abort();
const controller = new AbortController();
fetchAbortRef.current = controller;
const bounds = map.getBounds();
if (!bounds) return;
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const zoom = Math.round(map.getZoom());
const params = new URLSearchParams({
south: sw.lat.toString(),
west: sw.lng.toString(),
north: ne.lat.toString(),
east: ne.lng.toString(),
zoom: zoom.toString(),
...(includeOsmRawRef.current ? { includeOsmRaw: 'true' } : {}),
});
const res = await fetch(`${apiBase}?${params}`, {
credentials: 'include',
signal: controller.signal,
});
if (!res.ok) return;
const fc = (await res.json()) as GeoJSON.FeatureCollection;
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined;
if (src) src.setData(fc);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
console.warn('[osm-park-bbox-map] fetch failed:', err);
}
};
map.on('load', () => {
// Empty source — populated by the first fetchParks() call below.
map.addSource(SOURCE_ID, {
type: 'geojson',
data: { type: 'FeatureCollection', features: [] },
cluster: true,
clusterRadius: 50,
clusterMaxZoom: 11,
clusterProperties: {
// No extra metrics yet — total count is built-in.
},
});
// Cluster bubbles. Mapbox color parser only accepts literal colors,
// so we use hex constants matching our design-system primary token.
map.addLayer({
id: CLUSTER_LAYER_ID,
type: 'circle',
source: SOURCE_ID,
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#22c55e', // primary
10,
'#f59e0b',
50,
'#ef4444',
],
'circle-radius': [
'step',
['get', 'point_count'],
16,
10,
22,
50,
30,
],
'circle-stroke-width': 2,
'circle-stroke-color': '#ffffff',
'circle-opacity': 0.9,
},
});
map.addLayer({
id: CLUSTER_COUNT_LAYER_ID,
type: 'symbol',
source: SOURCE_ID,
filter: ['has', 'point_count'],
layout: {
'text-field': ['get', 'point_count_abbreviated'],
'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'],
'text-size': 12,
},
paint: { 'text-color': '#ffffff' },
});
// Individual park markers (centroid Points) when not clustered.
map.addLayer({
id: POINT_LAYER_ID,
type: 'circle',
source: SOURCE_ID,
filter: [
'all',
['!', ['has', 'point_count']],
['==', ['get', '_kind'], 'point'],
],
paint: {
'circle-color': '#22c55e',
'circle-radius': 6,
'circle-stroke-color': '#ffffff',
'circle-stroke-width': 1.5,
},
});
// Polygon outlines — only present when zoom >= 12 (server omits them
// at lower zoom). Fill layer for hit-test, line layer for stroke.
map.addLayer({
id: BOUNDARY_FILL_LAYER_ID,
type: 'fill',
source: SOURCE_ID,
filter: ['==', ['get', '_kind'], 'polygon'],
paint: {
'fill-color': '#22c55e',
'fill-opacity': 0.18,
},
});
map.addLayer({
id: BOUNDARY_LINE_LAYER_ID,
type: 'line',
source: SOURCE_ID,
filter: ['==', ['get', '_kind'], 'polygon'],
paint: {
'line-color': '#22c55e',
'line-width': 2,
'line-opacity': 0.6,
},
});
// Click handler on point/polygon → navigate to detail.
const onClick = (e: mapboxgl.MapLayerMouseEvent) => {
const f = e.features?.[0];
if (!f) return;
const slug = (f.properties as Record<string, unknown> | null)?.['slug'];
if (typeof slug === 'string' && slug.length > 0) {
window.location.href = `/vi/khu-cong-nghiep/${slug}`;
}
};
map.on('click', POINT_LAYER_ID, onClick);
map.on('click', BOUNDARY_FILL_LAYER_ID, onClick);
// Cursor feedback
for (const layerId of [POINT_LAYER_ID, BOUNDARY_FILL_LAYER_ID, CLUSTER_LAYER_ID]) {
map.on('mouseenter', layerId, () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', layerId, () => {
map.getCanvas().style.cursor = '';
});
}
// Cluster click — zoom in
map.on('click', CLUSTER_LAYER_ID, (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] });
const clusterFeature = features[0];
if (!clusterFeature) return;
const clusterId = clusterFeature.properties?.['cluster_id'];
const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource;
if (typeof clusterId === 'number') {
src.getClusterExpansionZoom(
clusterId,
(err: Error | null | undefined, zoom: number | null | undefined) => {
if (err || zoom == null) return;
const geom = clusterFeature.geometry;
if (geom.type === 'Point') {
map.easeTo({ center: geom.coordinates as [number, number], zoom });
}
},
);
}
});
// Initial fetch + listen to viewport changes.
void fetchParks();
});
map.on('moveend', () => {
void fetchParks();
});
return () => {
fetchAbortRef.current?.abort();
map.remove();
mapRef.current = null;
};
// We intentionally do NOT depend on includeOsmRaw — the ref-based
// approach avoids tearing down the map on every prop tick.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [apiBase]);
// Sync mapStyle (theme switch) without rebuilding the map.
React.useEffect(() => {
const map = mapRef.current;
if (!map) return;
map.setStyle(mapStyle);
}, [mapStyle]);
const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
return (
<div className={`relative overflow-hidden rounded-lg border ${className || 'h-[600px]'}`}>
<div ref={containerRef} className="h-full w-full" />
{!hasToken && (
<div className="absolute inset-0 flex items-center justify-center bg-muted text-sm text-muted-foreground">
Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN đ hiển thị bản đ
</div>
)}
</div>
);
}