Files
goodgo-platform/apps/web/lib/poi-api.ts
Ho Ngoc Hai fba536406d 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>
2026-05-01 12:01:19 +07:00

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()}`);
},
};