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:
Ho Ngoc Hai
2026-05-01 12:01:19 +07:00
parent 73ff469126
commit fba536406d
38 changed files with 3411 additions and 11 deletions

View File

@@ -0,0 +1,184 @@
import { Injectable } from '@nestjs/common';
import { LoggerService } from './logger.service';
import { PrismaService } from './prisma.service';
/**
* Result of a "where am I?" geo lookup. Each level may be null when the
* point lies outside any synced polygon (or when that level hasn't been
* synced yet — see PHASE_0 in the OSM rollout plan).
*/
export interface GeoLookupResult {
province: { code: string; name: string } | null;
district: { code: string; name: string } | null;
ward: { code: string; name: string } | null;
}
/**
* Centralised "lat/lng → administrative unit" resolver. Replaces the old
* `nearestProvince()` helper that walked a hard-coded centroid table —
* we now use real OSM-sourced polygons (PostGIS `ST_Contains`).
*
* Backed by the `vn_provinces` / `vn_districts` / `vn_wards` tables that
* `scripts/sync-osm-admin-boundaries.ts` populates. All three GIST-indexed
* geometry columns mean each lookup is O(log N).
*/
@Injectable()
export class GeoLookupService {
constructor(
private readonly prisma: PrismaService,
private readonly logger: LoggerService,
) {}
/**
* Resolve a point to the deepest administrative unit available. Returns
* partial results when the polygon hierarchy is incomplete (e.g. ward
* polygons not synced yet for that area).
*/
async lookup(lng: number, lat: number): Promise<GeoLookupResult> {
if (!this.isFiniteCoord(lng, lat)) {
return { province: null, district: null, ward: null };
}
// Province first — fastest GIST lookup, parents the other two.
const provinceRows = await this.prisma.$queryRawUnsafe<
{ code: string; name: string }[]
>(
`SELECT code, name FROM "vn_provinces"
WHERE geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
LIMIT 1`,
lng,
lat,
);
const province = provinceRows[0] ?? null;
if (!province) return { province: null, district: null, ward: null };
// District scoped to the matched province for speed + correctness
// around shared borders.
const districtRows = await this.prisma.$queryRawUnsafe<
{ code: string; name: string }[]
>(
`SELECT code, name FROM "vn_districts"
WHERE "provinceCode" = $3
AND geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
LIMIT 1`,
lng,
lat,
province.code,
);
const district = districtRows[0] ?? null;
if (!district) {
return { province, district: null, ward: null };
}
const wardRows = await this.prisma.$queryRawUnsafe<
{ code: string; name: string }[]
>(
`SELECT code, name FROM "vn_wards"
WHERE "districtCode" = $3
AND geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
LIMIT 1`,
lng,
lat,
district.code,
);
const ward = wardRows[0] ?? null;
return { province, district, ward };
}
/** Convenience wrapper that returns just the province display name. */
async findProvinceName(lng: number, lat: number): Promise<string | null> {
const r = await this.lookup(lng, lat);
return r.province?.name ?? null;
}
/** True if any province polygon contains the point — i.e. point is in VN. */
async isInVietnam(lng: number, lat: number): Promise<boolean> {
if (!this.isFiniteCoord(lng, lat)) return false;
const rows = await this.prisma.$queryRawUnsafe<{ exists: boolean }[]>(
`SELECT EXISTS (
SELECT 1 FROM "vn_provinces"
WHERE geometry IS NOT NULL
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
) AS exists`,
lng,
lat,
);
return rows[0]?.exists ?? false;
}
/**
* Coverage report for the admin dashboard: how many polygons of each
* level we have, and when each was last refreshed. Cheap aggregate.
*/
async coverage(): Promise<{
provinces: { total: number; withGeometry: number; lastSyncedAt: Date | null };
districts: { total: number; withGeometry: number; lastSyncedAt: Date | null };
wards: { total: number; withGeometry: number; lastSyncedAt: Date | null };
}> {
const [p, d, w] = await Promise.all([
this.prisma.$queryRawUnsafe<
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
>(
`SELECT COUNT(*)::bigint AS total,
COUNT(geometry)::bigint AS "withGeometry",
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "vn_provinces"`,
),
this.prisma.$queryRawUnsafe<
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
>(
`SELECT COUNT(*)::bigint AS total,
COUNT(geometry)::bigint AS "withGeometry",
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "vn_districts"`,
),
this.prisma.$queryRawUnsafe<
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
>(
`SELECT COUNT(*)::bigint AS total,
COUNT(geometry)::bigint AS "withGeometry",
MAX("lastSyncedAt") AS "lastSyncedAt"
FROM "vn_wards"`,
),
]);
return {
provinces: {
total: Number(p[0]?.total ?? 0n),
withGeometry: Number(p[0]?.withGeometry ?? 0n),
lastSyncedAt: p[0]?.lastSyncedAt ?? null,
},
districts: {
total: Number(d[0]?.total ?? 0n),
withGeometry: Number(d[0]?.withGeometry ?? 0n),
lastSyncedAt: d[0]?.lastSyncedAt ?? null,
},
wards: {
total: Number(w[0]?.total ?? 0n),
withGeometry: Number(w[0]?.withGeometry ?? 0n),
lastSyncedAt: w[0]?.lastSyncedAt ?? null,
},
};
}
private isFiniteCoord(lng: number, lat: number): boolean {
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
this.logger.warn(
`GeoLookupService: invalid coordinates lng=${lng} lat=${lat}`,
'GeoLookupService',
);
return false;
}
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) {
this.logger.warn(
`GeoLookupService: out-of-range coordinates lng=${lng} lat=${lat}`,
'GeoLookupService',
);
return false;
}
return true;
}
}

View File

@@ -9,6 +9,7 @@ export {
type ModelEncryptionFieldConfig,
} from './field-encryption.service';
export { createEncryptionExtension } from './encryption-middleware';
export { GeoLookupService, type GeoLookupResult } from './geo-lookup.service';
export { PrismaService } from './prisma.service';
export { RedisService } from './redis.service';
export { RedisIoAdapter } from './redis-io.adapter';

View File

@@ -17,6 +17,7 @@ import {
// import { EVENT_BUS, RedisStreamsEventBus } from './infrastructure/event-bus';
import { EventBusService } from './infrastructure/event-bus.service';
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
import { GeoLookupService } from './infrastructure/geo-lookup.service';
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
import { DeprecationInterceptor, VersionInterceptor } from './infrastructure/interceptors';
import { LoggerService } from './infrastructure/logger.service';
@@ -43,6 +44,7 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
RedisService,
CacheService,
EventBusService,
GeoLookupService,
// RFC-004 Phase 0 (GOO-172) — see import comment above.
// { provide: EVENT_BUS, useClass: RedisStreamsEventBus },
// OutboxService,
@@ -78,7 +80,17 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
useClass: DeprecationInterceptor,
},
],
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
exports: [
PrismaService,
RedisService,
CacheService,
LoggerService,
EventBusService,
FieldEncryptionService,
GeoLookupService,
TypesenseClientService,
PrometheusModule,
],
})
export class SharedModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {