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>
141 lines
5.0 KiB
TypeScript
141 lines
5.0 KiB
TypeScript
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()}`);
|
|
},
|
|
};
|