feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator
This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.
Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
`boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
filters to mainland VN via the existing country polygon, resolves the
GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
`nearestProvince()` heuristic — `lookup(lng,lat)` returns
province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
and falls back to the centroid heuristic only when polygons aren't
loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
IndustrialPark, ProjectDevelopment and Property using the new lookup.
Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
GIST index on `location`. New `TransportLine` for metro/highway
multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
resolves province/district codes from the boundary polygons, upserts
with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
GET /poi/by-bbox — GeoJSON for map overlays
GET /poi/nearby — sidebar "tiện ích xung quanh" (HMAC distance ranks)
GET /poi/coverage — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
project / KCN detail pages.
Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
(RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
Daily 02:00 → POI category rotation (1/day, 20-day cycle)
Mon 02:30 → admin-boundaries provinces
Wed 02:30 → admin-boundaries districts
Sat 02:30 → admin-boundaries wards
1st of month 03:00 → industrial-parks (per chunk)
All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
per-row "Sync now", recent runs list with auto-refresh every 15s.
Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
58
apps/web/lib/osm-sync-api.ts
Normal file
58
apps/web/lib/osm-sync-api.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
export interface OsmCoverageRow {
|
||||
layer: string;
|
||||
category: string | null;
|
||||
total: number;
|
||||
withGeometry?: number;
|
||||
promoted?: number;
|
||||
raw?: number;
|
||||
lastSyncedAt: string | null;
|
||||
}
|
||||
|
||||
export interface OsmCoverageSummary {
|
||||
rows: OsmCoverageRow[];
|
||||
totals: {
|
||||
administrativeUnits: number;
|
||||
poiTotal: number;
|
||||
industrialParks: number;
|
||||
transportStations: number;
|
||||
transportLines: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OsmSyncRun {
|
||||
id: string;
|
||||
layer: string;
|
||||
category: string | null;
|
||||
chunk: string | null;
|
||||
startedAt: string;
|
||||
finishedAt: string | null;
|
||||
status: 'RUNNING' | 'SUCCESS' | 'PARTIAL' | 'FAILED';
|
||||
rowsAdded: number;
|
||||
rowsUpdated: number;
|
||||
rowsSkipped: number;
|
||||
rowsLocked: number;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface OsmSyncLayer {
|
||||
layer: string;
|
||||
category?: string;
|
||||
weight: 'light' | 'medium' | 'heavy';
|
||||
}
|
||||
|
||||
export const osmSyncApi = {
|
||||
layers: () => apiClient.get<OsmSyncLayer[]>('/admin/osm/layers'),
|
||||
coverage: () => apiClient.get<OsmCoverageSummary>('/admin/osm/coverage'),
|
||||
runs: (params: { layer?: string; status?: string; limit?: number } = {}) => {
|
||||
const q = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== '') q.append(k, String(v));
|
||||
});
|
||||
const qs = q.toString();
|
||||
return apiClient.get<OsmSyncRun[]>(`/admin/osm/runs${qs ? `?${qs}` : ''}`);
|
||||
},
|
||||
trigger: (body: { layer: string; category?: string; chunk?: string }) =>
|
||||
apiClient.post<{ runId: string; status: string }>('/admin/osm/runs', body),
|
||||
};
|
||||
140
apps/web/lib/poi-api.ts
Normal file
140
apps/web/lib/poi-api.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { apiClient } from './api-client';
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export type PoiCategory =
|
||||
| 'SCHOOL_PRIMARY' | 'SCHOOL_SECONDARY' | 'UNIVERSITY'
|
||||
| 'HOSPITAL' | 'CLINIC' | 'PHARMACY'
|
||||
| 'MARKET' | 'SUPERMARKET' | 'MALL' | 'CONVENIENCE'
|
||||
| 'BANK' | 'ATM'
|
||||
| 'PARK'
|
||||
| 'GAS_STATION' | 'POLICE' | 'POST_OFFICE'
|
||||
| 'METRO_STATION' | 'RAILWAY_STATION' | 'BUS_STATION' | 'AIRPORT';
|
||||
|
||||
/** Vietnamese display labels for each POI category. */
|
||||
export const POI_LABELS: Record<PoiCategory, string> = {
|
||||
SCHOOL_PRIMARY: 'Trường tiểu học',
|
||||
SCHOOL_SECONDARY: 'Trường THCS / THPT',
|
||||
UNIVERSITY: 'Đại học / Cao đẳng',
|
||||
HOSPITAL: 'Bệnh viện',
|
||||
CLINIC: 'Phòng khám',
|
||||
PHARMACY: 'Nhà thuốc',
|
||||
MARKET: 'Chợ',
|
||||
SUPERMARKET: 'Siêu thị',
|
||||
MALL: 'TTTM',
|
||||
CONVENIENCE: 'Cửa hàng tiện lợi',
|
||||
BANK: 'Ngân hàng',
|
||||
ATM: 'ATM',
|
||||
PARK: 'Công viên',
|
||||
GAS_STATION: 'Cây xăng',
|
||||
POLICE: 'Công an',
|
||||
POST_OFFICE: 'Bưu điện',
|
||||
METRO_STATION: 'Ga Metro',
|
||||
RAILWAY_STATION: 'Ga tàu',
|
||||
BUS_STATION: 'Bến xe',
|
||||
AIRPORT: 'Sân bay',
|
||||
};
|
||||
|
||||
/** Single-emoji icon for chips / map markers (no extra image dep needed). */
|
||||
export const POI_ICONS: Record<PoiCategory, string> = {
|
||||
SCHOOL_PRIMARY: '🏫', SCHOOL_SECONDARY: '🎒', UNIVERSITY: '🎓',
|
||||
HOSPITAL: '🏥', CLINIC: '⚕️', PHARMACY: '💊',
|
||||
MARKET: '🛒', SUPERMARKET: '🏪', MALL: '🛍️', CONVENIENCE: '🏬',
|
||||
BANK: '🏦', ATM: '🏧',
|
||||
PARK: '🌳',
|
||||
GAS_STATION: '⛽', POLICE: '👮', POST_OFFICE: '📮',
|
||||
METRO_STATION: '🚇', RAILWAY_STATION: '🚉', BUS_STATION: '🚌', AIRPORT: '✈️',
|
||||
};
|
||||
|
||||
/** Tailwind colour class per category — keep marker coding consistent. */
|
||||
export const POI_COLORS: Record<PoiCategory, string> = {
|
||||
SCHOOL_PRIMARY: '#3b82f6', SCHOOL_SECONDARY: '#2563eb', UNIVERSITY: '#1d4ed8',
|
||||
HOSPITAL: '#ef4444', CLINIC: '#f87171', PHARMACY: '#fb7185',
|
||||
MARKET: '#f59e0b', SUPERMARKET: '#fbbf24', MALL: '#fcd34d', CONVENIENCE: '#fde68a',
|
||||
BANK: '#8b5cf6', ATM: '#a78bfa',
|
||||
PARK: '#22c55e',
|
||||
GAS_STATION: '#64748b', POLICE: '#0f172a', POST_OFFICE: '#be185d',
|
||||
METRO_STATION: '#0ea5e9', RAILWAY_STATION: '#0284c7', BUS_STATION: '#0369a1', AIRPORT: '#075985',
|
||||
};
|
||||
|
||||
export interface NearbyPoi {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PoiCategory;
|
||||
distanceM: number;
|
||||
lat: number;
|
||||
lng: number;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
export interface NearbyPoiResult {
|
||||
byCategory: Partial<Record<PoiCategory, NearbyPoi[]>>;
|
||||
all: NearbyPoi[];
|
||||
meta: { radiusMeters: number; totalCount: number; requestedCategories: PoiCategory[] | null };
|
||||
}
|
||||
|
||||
export interface PoiBboxFeatureCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: {
|
||||
id: string;
|
||||
name: string;
|
||||
category: PoiCategory;
|
||||
provinceCode: string | null;
|
||||
districtCode: string | null;
|
||||
};
|
||||
}[];
|
||||
meta: { count: number; truncated: boolean; categories: PoiCategory[] };
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* API */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
export const poiApi = {
|
||||
/**
|
||||
* Fetch nearest N POI (per category) within `radius` metres of the given
|
||||
* point. Drives the "tiện ích xung quanh" sidebar.
|
||||
*/
|
||||
nearby: (params: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius: number;
|
||||
categories?: PoiCategory[];
|
||||
limitPerCategory?: number;
|
||||
}): Promise<NearbyPoiResult> => {
|
||||
const q = new URLSearchParams({
|
||||
lat: String(params.lat),
|
||||
lng: String(params.lng),
|
||||
radius: String(params.radius),
|
||||
});
|
||||
if (params.categories?.length) q.set('categories', params.categories.join(','));
|
||||
if (params.limitPerCategory) q.set('limitPerCategory', String(params.limitPerCategory));
|
||||
return apiClient.get<NearbyPoiResult>(`/poi/nearby?${q.toString()}`);
|
||||
},
|
||||
|
||||
/** GeoJSON for map overlays. Used by the listing detail mini-map and KCN page. */
|
||||
byBbox: (params: {
|
||||
south: number;
|
||||
west: number;
|
||||
north: number;
|
||||
east: number;
|
||||
categories?: PoiCategory[];
|
||||
limit?: number;
|
||||
}): Promise<PoiBboxFeatureCollection> => {
|
||||
const q = new URLSearchParams({
|
||||
south: String(params.south),
|
||||
west: String(params.west),
|
||||
north: String(params.north),
|
||||
east: String(params.east),
|
||||
});
|
||||
if (params.categories?.length) q.set('categories', params.categories.join(','));
|
||||
if (params.limit) q.set('limit', String(params.limit));
|
||||
return apiClient.get<PoiBboxFeatureCollection>(`/poi/by-bbox?${q.toString()}`);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user