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:
184
apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts
Normal file
184
apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user