From fba536406d37d04831badadbe33c6dcfb2d15004 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Fri, 1 May 2026 12:01:19 +0700 Subject: [PATCH] =?UTF-8?q?feat(osm):=20foundation=20=E2=80=94=20admin=20b?= =?UTF-8?q?oundaries,=20POI=20catalog,=20sync=20orchestrator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_`), 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 `` 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) --- apps/api/src/app.module.ts | 4 + .../trigger-sync/trigger-sync.command.ts | 8 + .../trigger-sync/trigger-sync.handler.ts | 17 + .../coverage-summary.handler.ts | 127 +++++ .../coverage-summary.query.ts | 3 + .../queries/list-runs/list-runs.handler.ts | 22 + .../queries/list-runs/list-runs.query.ts | 8 + .../cron/osm-sync-cron.service.ts | 80 +++ .../infrastructure/osm-sync.service.ts | 239 ++++++++ .../src/modules/osm-sync/osm-sync.module.ts | 22 + .../controllers/osm-sync.controller.ts | 64 +++ .../presentation/dto/trigger-sync.dto.ts | 8 + .../coverage-stats/coverage-stats.handler.ts | 55 ++ .../coverage-stats/coverage-stats.query.ts | 5 + .../find-nearby-poi.handler.ts | 123 +++++ .../find-nearby-poi/find-nearby-poi.query.ts | 13 + .../list-poi-by-bbox.handler.ts | 99 ++++ .../list-poi-by-bbox.query.ts | 12 + apps/api/src/modules/poi/poi.module.ts | 19 + .../controllers/poi.controller.ts | 55 ++ .../presentation/dto/find-nearby-poi.dto.ts | 27 + .../presentation/dto/list-poi-by-bbox.dto.ts | 27 + .../infrastructure/geo-lookup.service.ts | 184 +++++++ .../modules/shared/infrastructure/index.ts | 1 + apps/api/src/modules/shared/shared.module.ts | 14 +- .../app/[locale]/(admin)/admin/osm/page.tsx | 321 +++++++++++ apps/web/app/[locale]/(admin)/layout.tsx | 2 + .../web/components/poi/nearby-poi-sidebar.tsx | 151 +++++ apps/web/lib/osm-sync-api.ts | 58 ++ apps/web/lib/poi-api.ts | 140 +++++ .../migration.sql | 51 ++ .../migration.sql | 77 +++ .../migration.sql | 25 + prisma/schema.prisma | 191 ++++++- scripts/backfill-admin-codes.ts | 216 ++++++++ scripts/sync-osm-admin-boundaries.ts | 519 ++++++++++++++++++ scripts/sync-osm-industrial-parks.ts | 35 +- scripts/sync-osm-poi.ts | 400 ++++++++++++++ 38 files changed, 3411 insertions(+), 11 deletions(-) create mode 100644 apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.command.ts create mode 100644 apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.handler.ts create mode 100644 apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.handler.ts create mode 100644 apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.query.ts create mode 100644 apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.handler.ts create mode 100644 apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.query.ts create mode 100644 apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts create mode 100644 apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts create mode 100644 apps/api/src/modules/osm-sync/osm-sync.module.ts create mode 100644 apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts create mode 100644 apps/api/src/modules/osm-sync/presentation/dto/trigger-sync.dto.ts create mode 100644 apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.handler.ts create mode 100644 apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.query.ts create mode 100644 apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.handler.ts create mode 100644 apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.query.ts create mode 100644 apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.handler.ts create mode 100644 apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.query.ts create mode 100644 apps/api/src/modules/poi/poi.module.ts create mode 100644 apps/api/src/modules/poi/presentation/controllers/poi.controller.ts create mode 100644 apps/api/src/modules/poi/presentation/dto/find-nearby-poi.dto.ts create mode 100644 apps/api/src/modules/poi/presentation/dto/list-poi-by-bbox.dto.ts create mode 100644 apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts create mode 100644 apps/web/app/[locale]/(admin)/admin/osm/page.tsx create mode 100644 apps/web/components/poi/nearby-poi-sidebar.tsx create mode 100644 apps/web/lib/osm-sync-api.ts create mode 100644 apps/web/lib/poi-api.ts create mode 100644 prisma/migrations/20260501000000_add_geometry_to_vn_admin/migration.sql create mode 100644 prisma/migrations/20260501010000_add_poi_and_transport/migration.sql create mode 100644 prisma/migrations/20260501020000_add_osm_sync_run/migration.sql create mode 100644 scripts/backfill-admin-codes.ts create mode 100644 scripts/sync-osm-admin-boundaries.ts create mode 100644 scripts/sync-osm-poi.ts diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index b58601b..3cbcb02 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -20,7 +20,9 @@ import { McpIntegrationModule } from '@modules/mcp'; import { MessagingModule } from '@modules/messaging'; import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics'; import { NotificationsModule } from '@modules/notifications'; +import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module'; import { PaymentsModule } from '@modules/payments'; +import { PoiModule } from '@modules/poi/poi.module'; import { ProjectsModule } from '@modules/projects'; import { QueuesModule } from '@modules/queues/queues.module'; import { ReportsModule } from '@modules/reports'; @@ -58,7 +60,9 @@ import { AppController } from './app.controller'; FavoritesModule, SearchModule, NotificationsModule, + OsmSyncModule, PaymentsModule, + PoiModule, SubscriptionsModule, AdminModule, AnalyticsModule, diff --git a/apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.command.ts b/apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.command.ts new file mode 100644 index 0000000..2421429 --- /dev/null +++ b/apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.command.ts @@ -0,0 +1,8 @@ +/** Manually trigger an OSM sync run from the admin UI. */ +export class TriggerOsmSyncCommand { + constructor( + public readonly layer: string, + public readonly category?: string | null, + public readonly chunk?: string | null, + ) {} +} diff --git a/apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.handler.ts b/apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.handler.ts new file mode 100644 index 0000000..303fcc4 --- /dev/null +++ b/apps/api/src/modules/osm-sync/application/commands/trigger-sync/trigger-sync.handler.ts @@ -0,0 +1,17 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { OsmSyncService } from '../../../infrastructure/osm-sync.service'; +import { TriggerOsmSyncCommand } from './trigger-sync.command'; + +@CommandHandler(TriggerOsmSyncCommand) +export class TriggerOsmSyncHandler implements ICommandHandler { + constructor(private readonly osmSync: OsmSyncService) {} + + async execute(cmd: TriggerOsmSyncCommand): Promise<{ runId: string; status: string }> { + return this.osmSync.run({ + layer: cmd.layer, + category: cmd.category ?? null, + chunk: cmd.chunk ?? null, + wait: false, + }); + } +} diff --git a/apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.handler.ts b/apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.handler.ts new file mode 100644 index 0000000..2c9fafb --- /dev/null +++ b/apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.handler.ts @@ -0,0 +1,127 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { GeoLookupService, PrismaService } from '@modules/shared'; +import { OsmCoverageSummaryQuery } from './coverage-summary.query'; + +export interface CoverageRow { + layer: string; + category: string | null; + total: number; + withGeometry?: number; // only meaningful for admin boundaries + promoted?: number; + raw?: number; + lastSyncedAt: Date | null; +} + +export interface OsmCoverageSummary { + rows: CoverageRow[]; + totals: { + administrativeUnits: number; + poiTotal: number; + industrialParks: number; + transportStations: number; + transportLines: number; + }; +} + +/** + * Single endpoint that powers the `/admin/osm` dashboard top-of-page + * "what's where" panel. Aggregates per-layer counts so we don't need 5 + * separate API calls. + */ +@QueryHandler(OsmCoverageSummaryQuery) +export class OsmCoverageSummaryHandler + implements IQueryHandler +{ + constructor( + private readonly prisma: PrismaService, + private readonly geo: GeoLookupService, + ) {} + + async execute(): Promise { + const [adminCov, poiByCategory, parkTotal, transportPoiTotal, transportLineTotal] = + await Promise.all([ + this.geo.coverage(), + this.prisma.$queryRawUnsafe< + { + category: string; + total: bigint; + promoted: bigint; + raw: bigint; + lastSyncedAt: Date | null; + }[] + >( + `SELECT category::text AS category, + COUNT(*)::bigint AS total, + SUM(CASE WHEN "dataSource" = 'OSM_PROMOTED' THEN 1 ELSE 0 END)::bigint AS promoted, + SUM(CASE WHEN "dataSource" = 'OSM' THEN 1 ELSE 0 END)::bigint AS raw, + MAX("lastSyncedAt") AS "lastSyncedAt" + FROM "Poi" + GROUP BY category`, + ), + this.prisma.industrialPark.count(), + this.prisma.poi.count({ + where: { + category: { + in: ['METRO_STATION', 'RAILWAY_STATION', 'BUS_STATION', 'AIRPORT'], + }, + }, + }), + this.prisma.transportLine.count(), + ]); + + const rows: CoverageRow[] = []; + rows.push( + { + layer: 'admin-boundaries', + category: 'province', + total: adminCov.provinces.total, + withGeometry: adminCov.provinces.withGeometry, + lastSyncedAt: adminCov.provinces.lastSyncedAt, + }, + { + layer: 'admin-boundaries', + category: 'district', + total: adminCov.districts.total, + withGeometry: adminCov.districts.withGeometry, + lastSyncedAt: adminCov.districts.lastSyncedAt, + }, + { + layer: 'admin-boundaries', + category: 'ward', + total: adminCov.wards.total, + withGeometry: adminCov.wards.withGeometry, + lastSyncedAt: adminCov.wards.lastSyncedAt, + }, + ); + for (const p of poiByCategory) { + rows.push({ + layer: 'poi', + category: p.category, + total: Number(p.total), + promoted: Number(p.promoted), + raw: Number(p.raw), + lastSyncedAt: p.lastSyncedAt, + }); + } + rows.push({ + layer: 'industrial-parks', + category: null, + total: parkTotal, + lastSyncedAt: null, + }); + + return { + rows, + totals: { + administrativeUnits: + adminCov.provinces.withGeometry + + adminCov.districts.withGeometry + + adminCov.wards.withGeometry, + poiTotal: poiByCategory.reduce((sum, p) => sum + Number(p.total), 0), + industrialParks: parkTotal, + transportStations: transportPoiTotal, + transportLines: transportLineTotal, + }, + }; + } +} diff --git a/apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.query.ts b/apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.query.ts new file mode 100644 index 0000000..68a79e1 --- /dev/null +++ b/apps/api/src/modules/osm-sync/application/queries/coverage-summary/coverage-summary.query.ts @@ -0,0 +1,3 @@ +/** Aggregate coverage view across all OSM-managed tables for the + * admin dashboard. */ +export class OsmCoverageSummaryQuery {} diff --git a/apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.handler.ts b/apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.handler.ts new file mode 100644 index 0000000..87d4f95 --- /dev/null +++ b/apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.handler.ts @@ -0,0 +1,22 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { type OsmSyncRun, type OsmSyncStatus, type Prisma } from '@prisma/client'; +import { PrismaService } from '@modules/shared'; +import { ListOsmSyncRunsQuery } from './list-runs.query'; + +@QueryHandler(ListOsmSyncRunsQuery) +export class ListOsmSyncRunsHandler + implements IQueryHandler +{ + constructor(private readonly prisma: PrismaService) {} + + async execute(q: ListOsmSyncRunsQuery): Promise { + const where: Prisma.OsmSyncRunWhereInput = {}; + if (q.layer) where.layer = q.layer; + if (q.status) where.status = q.status as OsmSyncStatus; + return this.prisma.osmSyncRun.findMany({ + where, + orderBy: { startedAt: 'desc' }, + take: Math.min(Math.max(q.limit, 1), 200), + }); + } +} diff --git a/apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.query.ts b/apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.query.ts new file mode 100644 index 0000000..1c67574 --- /dev/null +++ b/apps/api/src/modules/osm-sync/application/queries/list-runs/list-runs.query.ts @@ -0,0 +1,8 @@ +/** List recent OSM sync runs for the admin dashboard. */ +export class ListOsmSyncRunsQuery { + constructor( + public readonly layer?: string, + public readonly status?: string, + public readonly limit: number = 50, + ) {} +} diff --git a/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts b/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts new file mode 100644 index 0000000..e72aab3 --- /dev/null +++ b/apps/api/src/modules/osm-sync/infrastructure/cron/osm-sync-cron.service.ts @@ -0,0 +1,80 @@ +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { LoggerService } from '@modules/shared'; +import { OsmSyncService } from '../osm-sync.service'; + +/** + * Scheduled sync runner. Spreads layer refreshes across the week so we + * never hit Overpass with two heavy queries simultaneously and stay + * under the per-IP rate limit. + * + * Schedule (Asia/Ho_Chi_Minh): + * - Daily 02:00 → POI category rotation (one per day, 20-day cycle) + * - Mon 02:30 → admin-boundaries level=4 (provinces, light) + * - Wed 02:30 → admin-boundaries level=6 (districts, medium) + * - Sat 02:30 → admin-boundaries level=8 (wards, heavy) + * - 1st of month 03:00 → industrial-parks (existing flow, kept here so + * everything routes through one orchestrator) + * + * All routes respect `OSM_SYNC_ENABLED=true` to allow disabling in dev. + */ +@Injectable() +export class OsmSyncCronService { + private readonly POI_CATEGORIES = [ + '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', + ]; + + constructor( + private readonly osmSync: OsmSyncService, + private readonly logger: LoggerService, + ) {} + + private isEnabled(): boolean { + return process.env['OSM_SYNC_ENABLED'] === 'true'; + } + + @Cron('0 2 * * *', { timeZone: 'Asia/Ho_Chi_Minh' }) + async dailyPoiRotation(): Promise { + if (!this.isEnabled()) return; + // Pick one category based on day-of-year so we cycle evenly. + const dayOfYear = Math.floor( + (Date.now() - new Date(new Date().getUTCFullYear(), 0, 0).getTime()) / 86_400_000, + ); + const category = this.POI_CATEGORIES[dayOfYear % this.POI_CATEGORIES.length]!; + this.logger.log(`Daily POI rotation: ${category}`, 'OsmSyncCronService'); + await this.osmSync.run({ layer: 'poi', category, wait: false }); + } + + @Cron('30 2 * * 1', { timeZone: 'Asia/Ho_Chi_Minh' }) // Monday + async weeklyProvinces(): Promise { + if (!this.isEnabled()) return; + await this.osmSync.run({ layer: 'admin-boundaries', category: 'province', wait: false }); + } + + @Cron('30 2 * * 3', { timeZone: 'Asia/Ho_Chi_Minh' }) // Wednesday + async weeklyDistricts(): Promise { + if (!this.isEnabled()) return; + await this.osmSync.run({ layer: 'admin-boundaries', category: 'district', wait: false }); + } + + @Cron('30 2 * * 6', { timeZone: 'Asia/Ho_Chi_Minh' }) // Saturday + async weeklyWards(): Promise { + if (!this.isEnabled()) return; + await this.osmSync.run({ layer: 'admin-boundaries', category: 'ward', wait: false }); + } + + @Cron('0 3 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' }) // 1st of month + async monthlyIndustrialParks(): Promise { + if (!this.isEnabled()) return; + // KCN sync runs per chunk to spread load. + for (const chunk of ['north', 'northCentral', 'southCentral', 'south']) { + await this.osmSync.run({ layer: 'industrial-parks', chunk, wait: true }); + } + } +} diff --git a/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts b/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts new file mode 100644 index 0000000..ab6f560 --- /dev/null +++ b/apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts @@ -0,0 +1,239 @@ +import { Injectable } from '@nestjs/common'; +import { OsmSyncStatus } from '@prisma/client'; +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import * as path from 'node:path'; +import { LoggerService, PrismaService } from '@modules/shared'; + +/** + * Catalog of every sync layer / category we know about. The orchestrator + * uses this to validate trigger requests, populate the admin UI, and + * decide which scripts to run on the cron schedule. + */ +export interface OsmSyncLayerDef { + layer: string; + category?: string; + /** Path of the tsx script under repo root. */ + scriptPath: string; + /** Extra CLI args appended after `--category=` etc. */ + extraArgs?: string[]; + /** Approx Overpass cost — used to spread cron schedule. */ + weight: 'light' | 'medium' | 'heavy'; +} + +export const SYNC_LAYERS: OsmSyncLayerDef[] = [ + { + layer: 'admin-boundaries', + category: 'province', + scriptPath: 'scripts/sync-osm-admin-boundaries.ts', + extraArgs: ['--level=4'], + weight: 'light', + }, + { + layer: 'admin-boundaries', + category: 'district', + scriptPath: 'scripts/sync-osm-admin-boundaries.ts', + extraArgs: ['--level=6'], + weight: 'medium', + }, + { + layer: 'admin-boundaries', + category: 'ward', + scriptPath: 'scripts/sync-osm-admin-boundaries.ts', + extraArgs: ['--level=8'], + weight: 'heavy', + }, + // POI categories — each one its own row so the dashboard shows progress + // per category and the cron can rotate them across days. + ...['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', + ].map((cat) => ({ + layer: 'poi', + category: cat, + scriptPath: 'scripts/sync-osm-poi.ts', + extraArgs: [`--category=${cat}`], + weight: cat === 'BANK' || cat === 'PHARMACY' || cat === 'CONVENIENCE' ? 'medium' : 'light', + })), + { + layer: 'industrial-parks', + scriptPath: 'scripts/sync-osm-industrial-parks.ts', + weight: 'heavy', + }, +]; + +/** + * Spawns the right tsx script for a given (layer, category, chunk) and + * tracks the run in `OsmSyncRun`. Used both by the cron service and the + * admin "Sync now" button. + */ +@Injectable() +export class OsmSyncService { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + /** Look up a sync layer by its (layer, category) tuple. */ + findLayer(layer: string, category?: string | null): OsmSyncLayerDef | undefined { + return SYNC_LAYERS.find( + (l) => l.layer === layer && (l.category ?? null) === (category ?? null), + ); + } + + list(): OsmSyncLayerDef[] { + return SYNC_LAYERS; + } + + /** + * Run a sync layer (script invocation). Inserts a RUNNING `OsmSyncRun`, + * captures script stdout/stderr line-by-line into the logger, and + * updates the row to SUCCESS / PARTIAL / FAILED + row counts on exit. + * + * Returns the persisted `OsmSyncRun.id` immediately if `wait=false` so + * the admin UI can poll, or after the script exits when `wait=true`. + */ + async run(opts: { + layer: string; + category?: string | null; + chunk?: string | null; + wait?: boolean; + }): Promise<{ runId: string; status: OsmSyncStatus }> { + const def = this.findLayer(opts.layer, opts.category); + if (!def) { + throw new Error(`Unknown OSM sync layer: ${opts.layer}/${opts.category ?? '-'}`); + } + + const args = [...(def.extraArgs ?? [])]; + if (opts.chunk) args.push(`--chunk=${opts.chunk}`); + const queryHash = createHash('sha256') + .update(`${def.scriptPath} ${args.join(' ')}`) + .digest('hex') + .slice(0, 16); + + const run = await this.prisma.osmSyncRun.create({ + data: { + layer: opts.layer, + category: opts.category ?? null, + chunk: opts.chunk ?? null, + status: OsmSyncStatus.RUNNING, + overpassQueryHash: queryHash, + }, + }); + this.logger.log( + `OSM sync started run=${run.id} layer=${opts.layer} category=${opts.category ?? '-'} chunk=${opts.chunk ?? '-'}`, + 'OsmSyncService', + ); + + const promise = this.spawnAndTrack(run.id, def, args); + if (opts.wait) { + const status = await promise; + return { runId: run.id, status }; + } + void promise.catch((err) => + this.logger.error( + `OSM sync ${run.id} background failure: ${err}`, + err instanceof Error ? err.stack : undefined, + 'OsmSyncService', + ), + ); + return { runId: run.id, status: OsmSyncStatus.RUNNING }; + } + + private async spawnAndTrack( + runId: string, + def: OsmSyncLayerDef, + args: string[], + ): Promise { + return new Promise((resolve) => { + const repoRoot = path.resolve(__dirname, '../../../../../../..'); + const child = spawn('pnpm', ['tsx', def.scriptPath, ...args], { + cwd: repoRoot, + env: { + ...process.env, + NODE_OPTIONS: '-r dotenv/config', + DOTENV_CONFIG_PATH: '.env', + }, + }); + + const stats = { added: 0, updated: 0, skipped: 0, locked: 0 }; + const errors: string[] = []; + + const parseLine = (line: string) => { + // Lines like: "inserted=12 updated=3 locked=1 skipped=0" + const m = line.match(/inserted=(\d+).*updated=(\d+).*locked=(\d+).*skipped=(\d+)/); + if (m) { + stats.added += Number(m[1]); + stats.updated += Number(m[2]); + stats.locked += Number(m[3]); + stats.skipped += Number(m[4]); + } + }; + + child.stdout?.on('data', (b) => { + for (const line of b.toString().split('\n')) { + if (!line.trim()) continue; + this.logger.log(`[${runId}] ${line.trim()}`, 'OsmSyncService'); + parseLine(line); + } + }); + child.stderr?.on('data', (b) => { + for (const line of b.toString().split('\n')) { + if (!line.trim()) continue; + this.logger.warn(`[${runId}] ${line.trim()}`, 'OsmSyncService'); + if (errors.length < 20) errors.push(line.trim().slice(0, 500)); + } + }); + + child.on('error', async (err) => { + await this.complete(runId, OsmSyncStatus.FAILED, stats, err.message); + resolve(OsmSyncStatus.FAILED); + }); + child.on('exit', async (code) => { + const status = + code === 0 + ? errors.length > 0 + ? OsmSyncStatus.PARTIAL + : OsmSyncStatus.SUCCESS + : OsmSyncStatus.FAILED; + await this.complete( + runId, + status, + stats, + status === OsmSyncStatus.SUCCESS + ? null + : `exit=${code}; ${errors.slice(0, 5).join(' | ')}`, + ); + resolve(status); + }); + }); + } + + private async complete( + runId: string, + status: OsmSyncStatus, + stats: { added: number; updated: number; skipped: number; locked: number }, + errorMessage: string | null, + ): Promise { + await this.prisma.osmSyncRun.update({ + where: { id: runId }, + data: { + status, + finishedAt: new Date(), + rowsAdded: stats.added, + rowsUpdated: stats.updated, + rowsSkipped: stats.skipped, + rowsLocked: stats.locked, + errorMessage, + }, + }); + this.logger.log( + `OSM sync ${runId} → ${status}: added=${stats.added} updated=${stats.updated} skipped=${stats.skipped} locked=${stats.locked}`, + 'OsmSyncService', + ); + } +} diff --git a/apps/api/src/modules/osm-sync/osm-sync.module.ts b/apps/api/src/modules/osm-sync/osm-sync.module.ts new file mode 100644 index 0000000..f911489 --- /dev/null +++ b/apps/api/src/modules/osm-sync/osm-sync.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { TriggerOsmSyncHandler } from './application/commands/trigger-sync/trigger-sync.handler'; +import { OsmCoverageSummaryHandler } from './application/queries/coverage-summary/coverage-summary.handler'; +import { ListOsmSyncRunsHandler } from './application/queries/list-runs/list-runs.handler'; +import { OsmSyncCronService } from './infrastructure/cron/osm-sync-cron.service'; +import { OsmSyncService } from './infrastructure/osm-sync.service'; +import { OsmSyncController } from './presentation/controllers/osm-sync.controller'; + +const Handlers = [ + TriggerOsmSyncHandler, + ListOsmSyncRunsHandler, + OsmCoverageSummaryHandler, +]; + +@Module({ + imports: [CqrsModule], + controllers: [OsmSyncController], + providers: [OsmSyncService, OsmSyncCronService, ...Handlers], + exports: [OsmSyncService], +}) +export class OsmSyncModule {} diff --git a/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts b/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts new file mode 100644 index 0000000..ed5ad03 --- /dev/null +++ b/apps/api/src/modules/osm-sync/presentation/controllers/osm-sync.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; +import { TriggerOsmSyncCommand } from '../../application/commands/trigger-sync/trigger-sync.command'; +import { OsmCoverageSummaryQuery } from '../../application/queries/coverage-summary/coverage-summary.query'; +import { ListOsmSyncRunsQuery } from '../../application/queries/list-runs/list-runs.query'; +import { OsmSyncService } from '../../infrastructure/osm-sync.service'; +import { TriggerSyncDto } from '../dto/trigger-sync.dto'; + +/** + * Admin-only endpoints powering the `/admin/osm` dashboard. Public users + * never hit this controller — guarded by JwtAuthGuard + RolesGuard(ADMIN). + */ +@ApiTags('osm-sync') +@ApiBearerAuth('JWT') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(UserRole.ADMIN) +@Controller('admin/osm') +export class OsmSyncController { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + private readonly osmSync: OsmSyncService, + ) {} + + @ApiOperation({ summary: 'List configured sync layers (catalog)' }) + @Get('layers') + layers(): { layer: string; category?: string; weight: string }[] { + return this.osmSync.list().map((l) => ({ + layer: l.layer, + category: l.category, + weight: l.weight, + })); + } + + @ApiOperation({ summary: 'Coverage summary across all layers' }) + @Get('coverage') + coverage() { + return this.queryBus.execute(new OsmCoverageSummaryQuery()); + } + + @ApiOperation({ summary: 'Recent sync runs (latest first)' }) + @Get('runs') + runs( + @Query('layer') layer?: string, + @Query('status') status?: string, + @Query('limit') limit?: string, + ) { + return this.queryBus.execute( + new ListOsmSyncRunsQuery(layer, status, limit ? Number(limit) : 50), + ); + } + + @ApiOperation({ summary: 'Trigger a sync run now (returns runId for polling)' }) + @ApiResponse({ status: 201, description: 'Sync started' }) + @Post('runs') + trigger(@Body() dto: TriggerSyncDto) { + return this.commandBus.execute( + new TriggerOsmSyncCommand(dto.layer, dto.category, dto.chunk), + ); + } +} diff --git a/apps/api/src/modules/osm-sync/presentation/dto/trigger-sync.dto.ts b/apps/api/src/modules/osm-sync/presentation/dto/trigger-sync.dto.ts new file mode 100644 index 0000000..56e9ba5 --- /dev/null +++ b/apps/api/src/modules/osm-sync/presentation/dto/trigger-sync.dto.ts @@ -0,0 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class TriggerSyncDto { + @ApiProperty({ example: 'admin-boundaries' }) @IsString() layer!: string; + @ApiProperty({ required: false, example: 'province' }) @IsOptional() @IsString() category?: string; + @ApiProperty({ required: false, example: 'north' }) @IsOptional() @IsString() chunk?: string; +} diff --git a/apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.handler.ts b/apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.handler.ts new file mode 100644 index 0000000..b475506 --- /dev/null +++ b/apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.handler.ts @@ -0,0 +1,55 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { PrismaService } from '@modules/shared'; +import { PoiCoverageStatsQuery } from './coverage-stats.query'; + +export interface PoiCoverageRow { + category: string; + total: number; + promoted: number; + raw: number; + lastSyncedAt: Date | null; +} + +@QueryHandler(PoiCoverageStatsQuery) +export class PoiCoverageStatsHandler + implements IQueryHandler +{ + constructor(private readonly prisma: PrismaService) {} + + async execute(q: PoiCoverageStatsQuery): Promise { + const provinceFilter = q.provinceCode ? `WHERE "provinceCode" = $1` : ''; + const params = q.provinceCode ? [q.provinceCode] : []; + + const rows = await this.prisma.$queryRawUnsafe< + { + category: string; + total: bigint; + promoted: bigint; + raw: bigint; + lastSyncedAt: Date | null; + }[] + >( + ` + SELECT + category::text AS category, + COUNT(*)::bigint AS total, + SUM(CASE WHEN "dataSource" = 'OSM_PROMOTED' THEN 1 ELSE 0 END)::bigint AS promoted, + SUM(CASE WHEN "dataSource" = 'OSM' THEN 1 ELSE 0 END)::bigint AS raw, + MAX("lastSyncedAt") AS "lastSyncedAt" + FROM "Poi" + ${provinceFilter} + GROUP BY category + ORDER BY total DESC + `, + ...params, + ); + + return rows.map((r) => ({ + category: r.category, + total: Number(r.total), + promoted: Number(r.promoted), + raw: Number(r.raw), + lastSyncedAt: r.lastSyncedAt, + })); + } +} diff --git a/apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.query.ts b/apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.query.ts new file mode 100644 index 0000000..4976f8d --- /dev/null +++ b/apps/api/src/modules/poi/application/queries/coverage-stats/coverage-stats.query.ts @@ -0,0 +1,5 @@ +/** Query: aggregate per-category counts so the admin /admin/osm dashboard + * can show "30k schools, 15k hospitals, …" without a join per row. */ +export class PoiCoverageStatsQuery { + constructor(public readonly provinceCode?: string | null) {} +} diff --git a/apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.handler.ts b/apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.handler.ts new file mode 100644 index 0000000..c6c545b --- /dev/null +++ b/apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.handler.ts @@ -0,0 +1,123 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { FindNearbyPoiQuery } from './find-nearby-poi.query'; + +export interface NearbyPoi { + id: string; + name: string; + category: string; + /** Great-circle distance in metres from the requested centre. */ + distanceM: number; + lat: number; + lng: number; + address: string | null; +} + +export interface NearbyPoiResult { + /** Grouped by category for easy rendering as "tiện ích" chips. */ + byCategory: Record; + /** Flat list ordered by distance — used by the map overlay layer. */ + all: NearbyPoi[]; + meta: { + radiusMeters: number; + totalCount: number; + requestedCategories: string[] | null; + }; +} + +@QueryHandler(FindNearbyPoiQuery) +export class FindNearbyPoiHandler + implements IQueryHandler +{ + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(q: FindNearbyPoiQuery): Promise { + const radius = Math.min(Math.max(q.radiusMeters, 50), 10_000); + const limitPerCat = Math.min(Math.max(q.limitPerCategory, 1), 50); + + const cleanCats = (q.categories ?? []) + .map((c) => c.trim().toUpperCase()) + .filter((c) => /^[A-Z_]+$/.test(c)); + const categoryFilter = cleanCats.length + ? `AND category::text IN (${cleanCats.map((c) => `'${c}'`).join(', ')})` + : ''; + + try { + // PostGIS `ST_DWithin` with `geography::` cast does the great-circle + // metres check. For each row we also compute the actual distance + // (cast back to geography) and rank within category. + const rows = await this.prisma.$queryRawUnsafe< + { + id: string; + name: string; + category: string; + address: string | null; + lat: number; + lng: number; + distance_m: number; + rank: number; + }[] + >( + ` + SELECT id, name, category, address, lat, lng, distance_m, rank FROM ( + SELECT + p.id, p.name, p.category::text AS category, p.address, + ST_Y(p.location::geometry) AS lat, + ST_X(p.location::geometry) AS lng, + ST_Distance(p.location::geography, c.center::geography) AS distance_m, + ROW_NUMBER() OVER ( + PARTITION BY p.category + ORDER BY ST_Distance(p.location::geography, c.center::geography) + ) AS rank + FROM "Poi" p, + (SELECT ST_SetSRID(ST_MakePoint($1, $2), 4326) AS center) c + WHERE p."isPublic" = true + AND p."dataSource"::text IN ('OSM', 'OSM_PROMOTED', 'MANUAL') + AND ST_DWithin(p.location::geography, c.center::geography, $3) + ${categoryFilter} + ) ranked + WHERE rank <= $4 + ORDER BY distance_m + `, + q.center.lng, + q.center.lat, + radius, + limitPerCat, + ); + + const items: NearbyPoi[] = rows.map((r) => ({ + id: r.id, + name: r.name, + category: r.category, + distanceM: Math.round(r.distance_m), + lat: r.lat, + lng: r.lng, + address: r.address, + })); + const byCategory: Record = {}; + for (const it of items) { + (byCategory[it.category] ??= []).push(it); + } + + return { + byCategory, + all: items, + meta: { + radiusMeters: radius, + totalCount: items.length, + requestedCategories: cleanCats.length ? cleanCats : null, + }, + }; + } catch (err) { + this.logger.error( + `Find nearby POI failed: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + this.constructor.name, + ); + throw err; + } + } +} diff --git a/apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.query.ts b/apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.query.ts new file mode 100644 index 0000000..52d75ef --- /dev/null +++ b/apps/api/src/modules/poi/application/queries/find-nearby-poi/find-nearby-poi.query.ts @@ -0,0 +1,13 @@ +/** + * Query: find POI within a radius around a centre point. Drives the + * "tiện ích xung quanh" sidebar on listing / project / KCN detail pages + * and the search filter "trong vòng X mét từ trường". + */ +export class FindNearbyPoiQuery { + constructor( + public readonly center: { lng: number; lat: number }, + public readonly radiusMeters: number, + public readonly categories: string[] | null, + public readonly limitPerCategory: number = 5, + ) {} +} diff --git a/apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.handler.ts b/apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.handler.ts new file mode 100644 index 0000000..ddd173a --- /dev/null +++ b/apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.handler.ts @@ -0,0 +1,99 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { LoggerService, PrismaService } from '@modules/shared'; +import type { Feature, FeatureCollection } from 'geojson'; +import { ListPoiByBboxQuery } from './list-poi-by-bbox.query'; + +interface BboxRow { + id: string; + name: string; + category: string; + province_code: string | null; + district_code: string | null; + point: string; // GeoJSON Point as text from ST_AsGeoJSON +} + +export interface PoiGeoCollection extends FeatureCollection { + meta: { + count: number; + truncated: boolean; + categories: string[]; + }; +} + +@QueryHandler(ListPoiByBboxQuery) +export class ListPoiByBboxHandler + implements IQueryHandler +{ + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(q: ListPoiByBboxQuery): Promise { + const { south, west, north, east } = q.bbox; + const limit = Math.min(Math.max(q.limit, 1), 5000); + + // Build optional category filter — Prisma can't safely interpolate enum + // arrays so we whitelist + inline. + const cleanCats = (q.categories ?? []) + .map((c) => c.trim().toUpperCase()) + .filter((c) => /^[A-Z_]+$/.test(c)); + const categoryFilter = cleanCats.length + ? `AND category::text IN (${cleanCats.map((c) => `'${c}'`).join(', ')})` + : ''; + + try { + const rows = await this.prisma.$queryRawUnsafe( + ` + SELECT id, name, category::text AS category, + "provinceCode" AS province_code, + "districtCode" AS district_code, + ST_AsGeoJSON(location) AS point + FROM "Poi" + WHERE "isPublic" = true + AND "dataSource"::text IN ('OSM', 'OSM_PROMOTED', 'MANUAL') + AND location && ST_MakeEnvelope($1, $2, $3, $4, 4326) + ${categoryFilter} + LIMIT ${limit + 1} + `, + west, + south, + east, + north, + ); + + const truncated = rows.length > limit; + const trimmed = truncated ? rows.slice(0, limit) : rows; + + const features: Feature[] = trimmed.map((r) => ({ + type: 'Feature', + id: r.id, + geometry: JSON.parse(r.point), + properties: { + id: r.id, + name: r.name, + category: r.category, + provinceCode: r.province_code, + districtCode: r.district_code, + }, + })); + + return { + type: 'FeatureCollection', + features, + meta: { + count: trimmed.length, + truncated, + categories: Array.from(new Set(trimmed.map((r) => r.category))), + }, + }; + } catch (err) { + this.logger.error( + `Poi bbox query failed: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + this.constructor.name, + ); + throw err; + } + } +} diff --git a/apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.query.ts b/apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.query.ts new file mode 100644 index 0000000..8dd2d6e --- /dev/null +++ b/apps/api/src/modules/poi/application/queries/list-poi-by-bbox/list-poi-by-bbox.query.ts @@ -0,0 +1,12 @@ +/** + * Query: list POI inside a Mapbox-style bounding box, filtered by category. + * Used by the public catalog map and the listing-detail "tiện ích xung + * quanh" chips. + */ +export class ListPoiByBboxQuery { + constructor( + public readonly bbox: { south: number; west: number; north: number; east: number }, + public readonly categories: string[] | null, + public readonly limit: number = 1000, + ) {} +} diff --git a/apps/api/src/modules/poi/poi.module.ts b/apps/api/src/modules/poi/poi.module.ts new file mode 100644 index 0000000..065700a --- /dev/null +++ b/apps/api/src/modules/poi/poi.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { CqrsModule } from '@nestjs/cqrs'; +import { PoiCoverageStatsHandler } from './application/queries/coverage-stats/coverage-stats.handler'; +import { FindNearbyPoiHandler } from './application/queries/find-nearby-poi/find-nearby-poi.handler'; +import { ListPoiByBboxHandler } from './application/queries/list-poi-by-bbox/list-poi-by-bbox.handler'; +import { PoiController } from './presentation/controllers/poi.controller'; + +const QueryHandlers = [ + ListPoiByBboxHandler, + FindNearbyPoiHandler, + PoiCoverageStatsHandler, +]; + +@Module({ + imports: [CqrsModule], + controllers: [PoiController], + providers: [...QueryHandlers], +}) +export class PoiModule {} diff --git a/apps/api/src/modules/poi/presentation/controllers/poi.controller.ts b/apps/api/src/modules/poi/presentation/controllers/poi.controller.ts new file mode 100644 index 0000000..7151b74 --- /dev/null +++ b/apps/api/src/modules/poi/presentation/controllers/poi.controller.ts @@ -0,0 +1,55 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { UserRole } from '@prisma/client'; +import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth'; +import { PoiCoverageStatsQuery } from '../../application/queries/coverage-stats/coverage-stats.query'; +import { FindNearbyPoiQuery } from '../../application/queries/find-nearby-poi/find-nearby-poi.query'; +import { ListPoiByBboxQuery } from '../../application/queries/list-poi-by-bbox/list-poi-by-bbox.query'; +import { FindNearbyPoiDto } from '../dto/find-nearby-poi.dto'; +import { ListPoiByBboxDto } from '../dto/list-poi-by-bbox.dto'; + +@ApiTags('poi') +@Controller('poi') +export class PoiController { + constructor(private readonly queryBus: QueryBus) {} + + @ApiOperation({ summary: 'POI in viewport (GeoJSON FeatureCollection)' }) + @ApiResponse({ status: 200, description: 'GeoJSON + meta' }) + @Get('by-bbox') + async byBbox(@Query() dto: ListPoiByBboxDto) { + return this.queryBus.execute( + new ListPoiByBboxQuery( + { south: dto.south, west: dto.west, north: dto.north, east: dto.east }, + dto.categories ?? null, + dto.limit ?? 1000, + ), + ); + } + + @ApiOperation({ + summary: 'POI within radius around a point', + description: + 'Drives "tiện ích xung quanh" sidebar. Returns up to N nearest POI per category.', + }) + @Get('nearby') + async nearby(@Query() dto: FindNearbyPoiDto) { + return this.queryBus.execute( + new FindNearbyPoiQuery( + { lng: dto.lng, lat: dto.lat }, + dto.radius, + dto.categories ?? null, + dto.limitPerCategory ?? 5, + ), + ); + } + + @ApiOperation({ summary: 'POI coverage stats per category (admin)' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('coverage') + async coverage(@Query('provinceCode') provinceCode?: string) { + return this.queryBus.execute(new PoiCoverageStatsQuery(provinceCode ?? null)); + } +} diff --git a/apps/api/src/modules/poi/presentation/dto/find-nearby-poi.dto.ts b/apps/api/src/modules/poi/presentation/dto/find-nearby-poi.dto.ts new file mode 100644 index 0000000..ad057cf --- /dev/null +++ b/apps/api/src/modules/poi/presentation/dto/find-nearby-poi.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class FindNearbyPoiDto { + @ApiProperty({ example: 10.762622 }) @Type(() => Number) @IsNumber() @Min(-90) @Max(90) + lat!: number; + @ApiProperty({ example: 106.660172 }) @Type(() => Number) @IsNumber() @Min(-180) @Max(180) + lng!: number; + + @ApiProperty({ example: 1500, description: 'Radius in metres (50 - 10000)' }) + @Type(() => Number) @IsInt() @Min(50) @Max(10_000) + radius!: number; + + @ApiProperty({ required: false, isArray: true }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => + typeof value === 'string' ? value.split(',').map((s) => s.trim()) : value, + ) + categories?: string[]; + + @ApiProperty({ required: false, default: 5 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50) + limitPerCategory?: number; +} diff --git a/apps/api/src/modules/poi/presentation/dto/list-poi-by-bbox.dto.ts b/apps/api/src/modules/poi/presentation/dto/list-poi-by-bbox.dto.ts new file mode 100644 index 0000000..e43d618 --- /dev/null +++ b/apps/api/src/modules/poi/presentation/dto/list-poi-by-bbox.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsArray, IsInt, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator'; + +export class ListPoiByBboxDto { + @ApiProperty({ example: 10.5 }) @Type(() => Number) @IsNumber() @Min(-90) @Max(90) + south!: number; + @ApiProperty({ example: 106.5 }) @Type(() => Number) @IsNumber() @Min(-180) @Max(180) + west!: number; + @ApiProperty({ example: 11.0 }) @Type(() => Number) @IsNumber() @Min(-90) @Max(90) + north!: number; + @ApiProperty({ example: 107.0 }) @Type(() => Number) @IsNumber() @Min(-180) @Max(180) + east!: number; + + @ApiProperty({ required: false, isArray: true, example: ['SCHOOL_PRIMARY', 'HOSPITAL'] }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + @Transform(({ value }) => + typeof value === 'string' ? value.split(',').map((s) => s.trim()) : value, + ) + categories?: string[]; + + @ApiProperty({ required: false, default: 1000 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(5000) + limit?: number; +} diff --git a/apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts b/apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts new file mode 100644 index 0000000..d0e2de0 --- /dev/null +++ b/apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/apps/api/src/modules/shared/infrastructure/index.ts b/apps/api/src/modules/shared/infrastructure/index.ts index ca76fa6..8c33657 100644 --- a/apps/api/src/modules/shared/infrastructure/index.ts +++ b/apps/api/src/modules/shared/infrastructure/index.ts @@ -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'; diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index 2bde5dd..fe1b6c6 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -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 { diff --git a/apps/web/app/[locale]/(admin)/admin/osm/page.tsx b/apps/web/app/[locale]/(admin)/admin/osm/page.tsx new file mode 100644 index 0000000..1a58cc0 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/osm/page.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { + AlertTriangle, + CheckCircle, + Clock, + Layers, + MapPin, + PlayCircle, + RefreshCw, + Train, + XCircle, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + osmSyncApi, + type OsmCoverageSummary, + type OsmSyncLayer, + type OsmSyncRun, +} from '@/lib/osm-sync-api'; + +const STATUS_STYLES: Record = { + RUNNING: 'bg-blue-100 text-blue-800 ring-blue-200', + SUCCESS: 'bg-emerald-100 text-emerald-800 ring-emerald-200', + PARTIAL: 'bg-amber-100 text-amber-800 ring-amber-200', + FAILED: 'bg-red-100 text-red-800 ring-red-200', +}; + +const STATUS_ICONS: Record = { + RUNNING: , + SUCCESS: , + PARTIAL: , + FAILED: , +}; + +function formatRelative(iso: string | null): string { + if (!iso) return '—'; + const d = new Date(iso); + const diff = Date.now() - d.getTime(); + if (diff < 60_000) return 'vừa xong'; + if (diff < 3_600_000) return `${Math.round(diff / 60_000)} phút trước`; + if (diff < 86_400_000) return `${Math.round(diff / 3_600_000)} giờ trước`; + return `${Math.round(diff / 86_400_000)} ngày trước`; +} + +function formatDuration(start: string, end: string | null): string { + const startMs = new Date(start).getTime(); + const endMs = end ? new Date(end).getTime() : Date.now(); + const sec = Math.round((endMs - startMs) / 1000); + if (sec < 60) return `${sec}s`; + if (sec < 3600) return `${Math.floor(sec / 60)}m ${sec % 60}s`; + return `${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`; +} + +export default function AdminOsmDashboardPage() { + const [coverage, setCoverage] = useState(null); + const [runs, setRuns] = useState([]); + const [layers, setLayers] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [triggering, setTriggering] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const [cov, rs, ls] = await Promise.all([ + osmSyncApi.coverage(), + osmSyncApi.runs({ limit: 30 }), + osmSyncApi.layers(), + ]); + setCoverage(cov); + setRuns(rs); + setLayers(ls); + } catch (e) { + setError(e instanceof Error ? e.message : 'Lỗi tải dashboard'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + const int = setInterval(refresh, 15_000); // poll while RUNNING runs visible + return () => clearInterval(int); + }, [refresh]); + + const trigger = async (layer: string, category?: string) => { + const key = `${layer}/${category ?? '-'}`; + setTriggering(key); + try { + await osmSyncApi.trigger({ layer, category }); + await refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : 'Trigger fail'); + } finally { + setTriggering(null); + } + }; + + return ( +
+
+
+

OSM Sync Dashboard

+

+ Đồng bộ OpenStreetMap → Goodgo: ranh giới hành chính, POI, KCN, giao thông. +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Top stats */} + {coverage && ( +
+ } + label="Đơn vị hành chính" + value={coverage.totals.administrativeUnits.toLocaleString('vi-VN')} + /> + } + label="POI tổng" + value={coverage.totals.poiTotal.toLocaleString('vi-VN')} + /> + } + label="KCN" + value={coverage.totals.industrialParks.toLocaleString('vi-VN')} + /> + } + label="Bến/Ga" + value={coverage.totals.transportStations.toLocaleString('vi-VN')} + /> + } + label="Tuyến giao thông" + value={coverage.totals.transportLines.toLocaleString('vi-VN')} + /> +
+ )} + + {/* Coverage table */} + + +
+

Coverage theo layer

+
+ + + + Layer / Category + Tổng + Promoted + Raw + Sync gần nhất + Hành động + + + + {coverage?.rows.map((r) => { + const key = `${r.layer}/${r.category ?? '-'}`; + const layerDef = layers.find( + (l) => l.layer === r.layer && (l.category ?? null) === (r.category ?? null), + ); + return ( + + +
{r.layer}
+ {r.category && ( +
{r.category}
+ )} +
+ + {r.total.toLocaleString('vi-VN')} + {r.withGeometry !== undefined && r.withGeometry !== r.total && ( + + ({r.withGeometry} có geom) + + )} + + + {r.promoted?.toLocaleString('vi-VN') ?? '—'} + + + {r.raw?.toLocaleString('vi-VN') ?? '—'} + + + {formatRelative(r.lastSyncedAt)} + + + {layerDef && ( + + )} + +
+ ); + })} +
+
+
+
+ + {/* Recent runs */} + + +
+

Sync runs gần đây

+
+ + + + Layer + Status + Added + Updated + Skipped + Bắt đầu + Thời gian + + + + {runs.length === 0 ? ( + + + Chưa có sync run nào. + + + ) : ( + runs.map((r) => ( + + +
{r.layer}
+ {r.category && ( +
{r.category}
+ )} +
+ + + {STATUS_ICONS[r.status]} + {r.status} + + + {r.rowsAdded} + {r.rowsUpdated} + {r.rowsSkipped} + + + {formatRelative(r.startedAt)} + + + {formatDuration(r.startedAt, r.finishedAt)} + +
+ )) + )} +
+
+
+
+
+ ); +} + +function StatCard({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( + + +
+ {icon} +
+
+
{label}
+
{value}
+
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx index 811ae71..74c0530 100644 --- a/apps/web/app/[locale]/(admin)/layout.tsx +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -7,6 +7,7 @@ import { ShieldCheck, Building2, Factory, + Globe, LogOut, Menu, Sparkles, @@ -38,6 +39,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 }, { href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory }, { href: '/admin/industrial/osm-review' as const, label: 'Review OSM (KCN)', icon: Factory }, + { href: '/admin/osm' as const, label: 'OSM Sync Dashboard', icon: Globe }, { href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles }, ]; diff --git a/apps/web/components/poi/nearby-poi-sidebar.tsx b/apps/web/components/poi/nearby-poi-sidebar.tsx new file mode 100644 index 0000000..e118a6a --- /dev/null +++ b/apps/web/components/poi/nearby-poi-sidebar.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { Loader2, MapPin } from 'lucide-react'; +import * as React from 'react'; +import { + POI_ICONS, + POI_LABELS, + poiApi, + type NearbyPoiResult, + type PoiCategory, +} from '@/lib/poi-api'; + +interface Props { + /** Centre coordinates of the asset (listing / project / KCN). */ + lat: number; + lng: number; + /** Search radius in metres. Default 1500m (~15 phút đi bộ). */ + radius?: number; + /** Restrict to these categories. Default: 6 most relevant for residential. */ + categories?: PoiCategory[]; + /** N nearest POI shown per category. */ + limitPerCategory?: number; + className?: string; +} + +const DEFAULT_CATEGORIES: PoiCategory[] = [ + 'SCHOOL_PRIMARY', + 'SCHOOL_SECONDARY', + 'HOSPITAL', + 'MARKET', + 'BANK', + 'METRO_STATION', +]; + +function formatDistance(m: number): string { + if (m < 1000) return `${m} m`; + return `${(m / 1000).toFixed(1)} km`; +} + +/** + * Sidebar widget that lists the nearest POI of each category around a + * geo-tagged asset. Renders inside listing detail, project detail and KCN + * detail pages. + */ +export function NearbyPoiSidebar({ + lat, + lng, + radius = 1500, + categories = DEFAULT_CATEGORIES, + limitPerCategory = 3, + className, +}: Props) { + const [data, setData] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + poiApi + .nearby({ lat, lng, radius, categories, limitPerCategory }) + .then((res) => { + if (cancelled) return; + setData(res); + }) + .catch((err: Error) => { + if (cancelled) return; + setError(err.message ?? 'Không tải được tiện ích'); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [lat, lng, radius, categories, limitPerCategory]); + + if (loading) { + return ( +
+ + Đang tải tiện ích xung quanh… +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!data || data.all.length === 0) { + return ( +
+ Chưa có dữ liệu tiện ích trong bán kính {formatDistance(radius)}. +
+ ); + } + + return ( +
+
+

Tiện ích xung quanh

+ + {data.meta.totalCount} điểm · bán kính {formatDistance(data.meta.radiusMeters)} + +
+
+ {categories.map((cat) => { + const items = data.byCategory[cat] ?? []; + if (items.length === 0) return null; + return ( +
+
+ {POI_ICONS[cat]} + {POI_LABELS[cat]} +
+
    + {items.map((p) => ( +
  • + +
    +
    {p.name}
    + {p.address && ( +
    {p.address}
    + )} +
    + + {formatDistance(p.distanceM)} + +
  • + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/apps/web/lib/osm-sync-api.ts b/apps/web/lib/osm-sync-api.ts new file mode 100644 index 0000000..9e61b65 --- /dev/null +++ b/apps/web/lib/osm-sync-api.ts @@ -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('/admin/osm/layers'), + coverage: () => apiClient.get('/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(`/admin/osm/runs${qs ? `?${qs}` : ''}`); + }, + trigger: (body: { layer: string; category?: string; chunk?: string }) => + apiClient.post<{ runId: string; status: string }>('/admin/osm/runs', body), +}; diff --git a/apps/web/lib/poi-api.ts b/apps/web/lib/poi-api.ts new file mode 100644 index 0000000..f3f2259 --- /dev/null +++ b/apps/web/lib/poi-api.ts @@ -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 = { + 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 = { + 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 = { + 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>; + 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 => { + 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(`/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 => { + 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(`/poi/by-bbox?${q.toString()}`); + }, +}; diff --git a/prisma/migrations/20260501000000_add_geometry_to_vn_admin/migration.sql b/prisma/migrations/20260501000000_add_geometry_to_vn_admin/migration.sql new file mode 100644 index 0000000..d97dbb8 --- /dev/null +++ b/prisma/migrations/20260501000000_add_geometry_to_vn_admin/migration.sql @@ -0,0 +1,51 @@ +-- Add PostGIS geometry + OSM provenance to vn_provinces / vn_districts / vn_wards. +-- Geometry is `MultiPolygon` (some provinces have offshore islands), centroid is `Point`. +-- All columns are nullable to allow incremental backfill from the Overpass sync. + +-- ── vn_provinces ──────────────────────────────────────────────────────────── +ALTER TABLE "vn_provinces" + ADD COLUMN IF NOT EXISTS "osmId" BIGINT, + ADD COLUMN IF NOT EXISTS "areaKm2" DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS "population" INTEGER, + ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +SELECT AddGeometryColumn('public', 'vn_provinces', 'geometry', 4326, 'MULTIPOLYGON', 2); +SELECT AddGeometryColumn('public', 'vn_provinces', 'centroid', 4326, 'POINT', 2); + +CREATE UNIQUE INDEX IF NOT EXISTS "vn_provinces_osmId_key" ON "vn_provinces"("osmId") WHERE "osmId" IS NOT NULL; +CREATE INDEX IF NOT EXISTS "vn_provinces_geometry_idx" ON "vn_provinces" USING GIST ("geometry"); +CREATE INDEX IF NOT EXISTS "vn_provinces_centroid_idx" ON "vn_provinces" USING GIST ("centroid"); +CREATE INDEX IF NOT EXISTS "vn_provinces_lastSyncedAt_idx" ON "vn_provinces"("lastSyncedAt"); + +-- ── vn_districts ──────────────────────────────────────────────────────────── +ALTER TABLE "vn_districts" + ADD COLUMN IF NOT EXISTS "osmId" BIGINT, + ADD COLUMN IF NOT EXISTS "areaKm2" DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS "population" INTEGER, + ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +SELECT AddGeometryColumn('public', 'vn_districts', 'geometry', 4326, 'MULTIPOLYGON', 2); +SELECT AddGeometryColumn('public', 'vn_districts', 'centroid', 4326, 'POINT', 2); + +CREATE UNIQUE INDEX IF NOT EXISTS "vn_districts_osmId_key" ON "vn_districts"("osmId") WHERE "osmId" IS NOT NULL; +CREATE INDEX IF NOT EXISTS "vn_districts_geometry_idx" ON "vn_districts" USING GIST ("geometry"); +CREATE INDEX IF NOT EXISTS "vn_districts_centroid_idx" ON "vn_districts" USING GIST ("centroid"); +CREATE INDEX IF NOT EXISTS "vn_districts_lastSyncedAt_idx" ON "vn_districts"("lastSyncedAt"); + +-- ── vn_wards ──────────────────────────────────────────────────────────────── +ALTER TABLE "vn_wards" + ADD COLUMN IF NOT EXISTS "osmId" BIGINT, + ADD COLUMN IF NOT EXISTS "areaKm2" DOUBLE PRECISION, + ADD COLUMN IF NOT EXISTS "population" INTEGER, + ADD COLUMN IF NOT EXISTS "lastSyncedAt" TIMESTAMP(3), + ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; + +SELECT AddGeometryColumn('public', 'vn_wards', 'geometry', 4326, 'MULTIPOLYGON', 2); +SELECT AddGeometryColumn('public', 'vn_wards', 'centroid', 4326, 'POINT', 2); + +CREATE UNIQUE INDEX IF NOT EXISTS "vn_wards_osmId_key" ON "vn_wards"("osmId") WHERE "osmId" IS NOT NULL; +CREATE INDEX IF NOT EXISTS "vn_wards_geometry_idx" ON "vn_wards" USING GIST ("geometry"); +CREATE INDEX IF NOT EXISTS "vn_wards_centroid_idx" ON "vn_wards" USING GIST ("centroid"); +CREATE INDEX IF NOT EXISTS "vn_wards_lastSyncedAt_idx" ON "vn_wards"("lastSyncedAt"); diff --git a/prisma/migrations/20260501010000_add_poi_and_transport/migration.sql b/prisma/migrations/20260501010000_add_poi_and_transport/migration.sql new file mode 100644 index 0000000..afac64b --- /dev/null +++ b/prisma/migrations/20260501010000_add_poi_and_transport/migration.sql @@ -0,0 +1,77 @@ +-- Phase 1: Poi catalog + TransportLine for OSM-sourced amenities and routes. + +-- ── Enums ────────────────────────────────────────────────────────────────── +DO $$ BEGIN + CREATE TYPE "PoiCategory" AS ENUM ( + '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' + ); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE "OsmType" AS ENUM ('NODE','WAY','RELATION'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +DO $$ BEGIN + CREATE TYPE "OsmDataSource" AS ENUM ('OSM','OSM_PROMOTED','MANUAL'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +-- ── Poi ──────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "Poi" ( + "id" TEXT PRIMARY KEY, + "category" "PoiCategory" NOT NULL, + "name" TEXT NOT NULL, + "nameEn" TEXT, + "address" TEXT, + "provinceCode" TEXT, + "districtCode" TEXT, + "wardCode" TEXT, + "osmId" BIGINT NOT NULL, + "osmType" "OsmType" NOT NULL, + "osmTags" JSONB NOT NULL, + "dataSource" "OsmDataSource" NOT NULL DEFAULT 'OSM', + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "osmLocked" BOOLEAN NOT NULL DEFAULT false, + "lockedFields" TEXT[] NOT NULL DEFAULT '{}', + "lastSyncedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "Poi_osmId_key" UNIQUE ("osmId") +); + +SELECT AddGeometryColumn('public', 'Poi', 'location', 4326, 'POINT', 2); +ALTER TABLE "Poi" ALTER COLUMN "location" SET NOT NULL; + +CREATE INDEX IF NOT EXISTS "Poi_location_idx" ON "Poi" USING GIST ("location"); +CREATE INDEX IF NOT EXISTS "Poi_cat_prov_idx" ON "Poi"("category","provinceCode"); +CREATE INDEX IF NOT EXISTS "Poi_cat_dist_idx" ON "Poi"("category","districtCode"); +CREATE INDEX IF NOT EXISTS "Poi_provinceCode_idx" ON "Poi"("provinceCode"); +CREATE INDEX IF NOT EXISTS "Poi_dataSource_pub" ON "Poi"("dataSource","isPublic"); +CREATE INDEX IF NOT EXISTS "Poi_lastSyncedAt_idx" ON "Poi"("lastSyncedAt"); + +-- ── TransportLine ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS "TransportLine" ( + "id" TEXT PRIMARY KEY, + "type" TEXT NOT NULL, + "name" TEXT NOT NULL, + "ref" TEXT, + "osmRelationId" BIGINT, + "status" TEXT NOT NULL DEFAULT 'operational', + "lengthKm" DOUBLE PRECISION, + "lastSyncedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "TransportLine_osmRelationId_key" UNIQUE ("osmRelationId") +); + +SELECT AddGeometryColumn('public', 'TransportLine', 'geometry', 4326, 'MULTILINESTRING', 2); +ALTER TABLE "TransportLine" ALTER COLUMN "geometry" SET NOT NULL; + +CREATE INDEX IF NOT EXISTS "TransportLine_geometry_idx" ON "TransportLine" USING GIST ("geometry"); +CREATE INDEX IF NOT EXISTS "TransportLine_type_idx" ON "TransportLine"("type"); +CREATE INDEX IF NOT EXISTS "TransportLine_status_idx" ON "TransportLine"("status"); diff --git a/prisma/migrations/20260501020000_add_osm_sync_run/migration.sql b/prisma/migrations/20260501020000_add_osm_sync_run/migration.sql new file mode 100644 index 0000000..2645211 --- /dev/null +++ b/prisma/migrations/20260501020000_add_osm_sync_run/migration.sql @@ -0,0 +1,25 @@ +-- Phase 4: persistent audit log of every OSM sync run. +DO $$ BEGIN + CREATE TYPE "OsmSyncStatus" AS ENUM ('RUNNING','SUCCESS','PARTIAL','FAILED'); +EXCEPTION WHEN duplicate_object THEN NULL; END $$; + +CREATE TABLE IF NOT EXISTS "OsmSyncRun" ( + "id" TEXT PRIMARY KEY, + "layer" TEXT NOT NULL, + "category" TEXT, + "chunk" TEXT, + "startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "finishedAt" TIMESTAMP(3), + "status" "OsmSyncStatus" NOT NULL DEFAULT 'RUNNING', + "rowsAdded" INTEGER NOT NULL DEFAULT 0, + "rowsUpdated" INTEGER NOT NULL DEFAULT 0, + "rowsSkipped" INTEGER NOT NULL DEFAULT 0, + "rowsLocked" INTEGER NOT NULL DEFAULT 0, + "errorMessage" TEXT, + "overpassQueryHash" TEXT, + "metadata" JSONB +); + +CREATE INDEX IF NOT EXISTS "OsmSyncRun_layer_started" ON "OsmSyncRun"("layer","startedAt"); +CREATE INDEX IF NOT EXISTS "OsmSyncRun_status_idx" ON "OsmSyncRun"("status"); +CREATE INDEX IF NOT EXISTS "OsmSyncRun_started_idx" ON "OsmSyncRun"("startedAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 286b3fb..032aee5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1574,15 +1574,31 @@ model SystemSetting { // [GOO-21] model VnProvince { - code String @id // GSO province code, zero-padded (e.g. "01", "79") - name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh" - nameEn String? - type String // "Thành phố Trung ương" | "Tỉnh" - codename String // slug, e.g. "thanh_pho_ho_chi_minh" - phoneCode Int? - districts VnDistrict[] + code String @id // GSO province code, zero-padded (e.g. "01", "79") + name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh" + nameEn String? + type String // "Thành phố Trung ương" | "Tỉnh" + codename String // slug, e.g. "thanh_pho_ho_chi_minh" + phoneCode Int? + /// OSM relation id for `boundary=administrative + admin_level=4`. Null until first sync. + osmId BigInt? @unique + /// PostGIS multipolygon (managed via raw SQL — Prisma can't model PostGIS). + geometry Unsupported("geometry(MultiPolygon, 4326)")? + /// Cached centroid for fast "show on map" without ST_Centroid every query. + centroid Unsupported("geometry(Point, 4326)")? + /// Surface area in km². Useful for density / coverage analytics. + areaKm2 Float? + /// Latest GSO population estimate when known. + population Int? + /// When the row was last refreshed from Overpass. + lastSyncedAt DateTime? + updatedAt DateTime @updatedAt + districts VnDistrict[] @@index([codename]) + @@index([geometry], type: Gist) + @@index([centroid], type: Gist) + @@index([lastSyncedAt]) @@map("vn_provinces") } @@ -1593,11 +1609,21 @@ model VnDistrict { nameEn String? type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh" codename String + osmId BigInt? @unique + geometry Unsupported("geometry(MultiPolygon, 4326)")? + centroid Unsupported("geometry(Point, 4326)")? + areaKm2 Float? + population Int? + lastSyncedAt DateTime? + updatedAt DateTime @updatedAt province VnProvince @relation(fields: [provinceCode], references: [code], onDelete: Restrict) wards VnWard[] @@index([provinceCode]) @@index([codename]) + @@index([geometry], type: Gist) + @@index([centroid], type: Gist) + @@index([lastSyncedAt]) @@map("vn_districts") } @@ -1608,15 +1634,166 @@ model VnWard { nameEn String? type String // "Phường" | "Xã" | "Thị trấn" codename String + osmId BigInt? @unique + geometry Unsupported("geometry(MultiPolygon, 4326)")? + centroid Unsupported("geometry(Point, 4326)")? + areaKm2 Float? + population Int? + lastSyncedAt DateTime? + updatedAt DateTime @updatedAt district VnDistrict @relation(fields: [districtCode], references: [code], onDelete: Restrict) @@index([districtCode]) @@index([codename]) + @@index([geometry], type: Gist) + @@index([centroid], type: Gist) + @@index([lastSyncedAt]) @@map("vn_wards") } /// Historical name/code changes so legacy data (e.g. Quận 2, Quận 9) and post-2025 /// merges can still resolve to the current district/ward. +/// Categories of OSM POI we ingest. Each maps to one or more Overpass +/// tag queries — see `scripts/sync-osm-poi.ts`. Adding a new value here +/// requires a Prisma migration. +enum PoiCategory { + // Education + SCHOOL_PRIMARY + SCHOOL_SECONDARY + UNIVERSITY + // Health + HOSPITAL + CLINIC + PHARMACY + // Commerce + MARKET + SUPERMARKET + MALL + CONVENIENCE + // Finance + BANK + ATM + // Recreation + PARK + // Services + GAS_STATION + POLICE + POST_OFFICE + // Transport (also tracked here for proximity scoring; lines live in TransportLine) + METRO_STATION + RAILWAY_STATION + BUS_STATION + AIRPORT +} + +enum OsmType { + NODE + WAY + RELATION +} + +enum OsmDataSource { + OSM + OSM_PROMOTED + MANUAL +} + +/// Catalog of points-of-interest sourced primarily from OSM. Backs the +/// "tiện ích xung quanh" feature on listing detail + KCN + project +/// proximity scoring + the search "within X meters" filters. +model Poi { + id String @id @default(cuid()) + category PoiCategory + name String + nameEn String? + /// PostGIS Point — managed via raw SQL because Prisma can't model + /// `geometry`. GIST-indexed for fast nearby-radius queries. + location Unsupported("geometry(Point, 4326)") + address String? + /// Resolved by `GeoLookupService` after insert (not part of OSM data). + provinceCode String? + districtCode String? + wardCode String? + /// OSM provenance — same model as IndustrialPark. + osmId BigInt @unique + osmType OsmType + osmTags Json + dataSource OsmDataSource @default(OSM) + isPublic Boolean @default(true) + osmLocked Boolean @default(false) + lockedFields String[] @default([]) + lastSyncedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([location], type: Gist) + @@index([category, provinceCode]) + @@index([category, districtCode]) + @@index([provinceCode]) + @@index([dataSource, isPublic]) + @@index([lastSyncedAt]) + @@map("Poi") +} + +/// Transport lines (metro / railway / highway routes) — the linear +/// counterpart to Poi station entries. Used to compute "distance to +/// nearest metro line" without joining 100k station pings. +model TransportLine { + id String @id @default(cuid()) + type String // METRO | RAILWAY | TRUNK | MOTORWAY | PRIMARY + name String // "Metro Số 1 Bến Thành - Suối Tiên" / "QL1A" + ref String? // "M1", "QL1A" + geometry Unsupported("geometry(MultiLineString, 4326)") + osmRelationId BigInt? @unique + status String @default("operational") // planned | under_construction | operational + lengthKm Float? + lastSyncedAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([geometry], type: Gist) + @@index([type]) + @@index([status]) + @@map("TransportLine") +} + +enum OsmSyncStatus { + RUNNING + SUCCESS + PARTIAL + FAILED +} + +/// Audit + monitoring record for every OSM sync run (admin boundaries, +/// POI categories, transport, KCN, etc.). Drives the `/admin/osm` +/// dashboard and Prometheus alerts. +model OsmSyncRun { + id String @id @default(cuid()) + /// Coarse layer name: "admin-boundaries" / "poi" / "transport" / "industrial-parks" + layer String + /// Fine-grained scope inside the layer, when applicable. + category String? + chunk String? + startedAt DateTime @default(now()) + finishedAt DateTime? + status OsmSyncStatus @default(RUNNING) + rowsAdded Int @default(0) + rowsUpdated Int @default(0) + rowsSkipped Int @default(0) + rowsLocked Int @default(0) + /// Truncated message for UI display; full stack lives in Loki. + errorMessage String? @db.Text + /// SHA-256 of the Overpass query so we can detect query drift. + overpassQueryHash String? + /// Free-form metadata (Overpass response size, kubectl run id, etc.). + metadata Json? + + @@index([layer, startedAt]) + @@index([status]) + @@index([startedAt]) + @@map("OsmSyncRun") +} + model VnAdministrativeAlias { id String @id @default(cuid()) oldCode String? // GSO code pre-change, when known diff --git a/scripts/backfill-admin-codes.ts b/scripts/backfill-admin-codes.ts new file mode 100644 index 0000000..5efc043 --- /dev/null +++ b/scripts/backfill-admin-codes.ts @@ -0,0 +1,216 @@ +/** + * Backfill `provinceCode` / `districtCode` / `wardCode` (and the human + * `province` / `district` / `ward` text columns where present) on every + * geo-bearing entity, using the freshly synced + * `vn_provinces` / `vn_districts` / `vn_wards` polygons. + * + * Tables processed: + * - IndustrialPark (PostGIS point) + * - ProjectDevelopment (PostGIS point) + * - Listing (uses Property.location internally — joined) + * - Property (PostGIS point — most listings live here) + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/backfill-admin-codes.ts [--dry-run] [--table=NAME] + * + * Strategy: + * For each entity with a `location` Point we ST_Contains against the + * province/district/ward polygons and write the matched code+name back + * into the row. Only rows where the resolved value DIFFERS from the + * existing one are touched, so re-runs are cheap. + */ +import 'dotenv/config'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import pg from 'pg'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const dryRun = process.argv.includes('--dry-run'); +const tableArg = process.argv.find((a) => a.startsWith('--table='))?.slice('--table='.length); + +interface AdminMatch { + provinceCode: string | null; + provinceName: string | null; + districtCode: string | null; + districtName: string | null; + wardCode: string | null; + wardName: string | null; +} + +/** + * Single SQL statement that joins a point against the 3 admin tables and + * returns whichever level matched. NULL when no province polygon contains + * the point (likely outside VN or polygons not synced for that area). + */ +async function resolve(lng: number, lat: number): Promise { + const rows = await prisma.$queryRawUnsafe< + { + provinceCode: string | null; + provinceName: string | null; + districtCode: string | null; + districtName: string | null; + wardCode: string | null; + wardName: string | null; + }[] + >( + `WITH p AS ( + SELECT code, name FROM "vn_provinces" + WHERE geometry IS NOT NULL + AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1 + ), + d AS ( + SELECT d.code, d.name + FROM "vn_districts" d + JOIN p ON p.code = d."provinceCode" + WHERE d.geometry IS NOT NULL + AND ST_Contains(d.geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1 + ), + w AS ( + SELECT w.code, w.name + FROM "vn_wards" w + JOIN d ON d.code = w."districtCode" + WHERE w.geometry IS NOT NULL + AND ST_Contains(w.geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1 + ) + SELECT + (SELECT code FROM p) AS "provinceCode", + (SELECT name FROM p) AS "provinceName", + (SELECT code FROM d) AS "districtCode", + (SELECT name FROM d) AS "districtName", + (SELECT code FROM w) AS "wardCode", + (SELECT name FROM w) AS "wardName"`, + lng, + lat, + ); + return ( + rows[0] ?? { + provinceCode: null, + provinceName: null, + districtCode: null, + districtName: null, + wardCode: null, + wardName: null, + } + ); +} + +async function backfillIndustrialPark(): Promise { + console.log('🏭 IndustrialPark…'); + const rows = await prisma.$queryRawUnsafe< + { id: string; lat: number; lng: number; province: string }[] + >( + `SELECT id, ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng, province + FROM "IndustrialPark"`, + ); + let updated = 0; + for (const r of rows) { + const m = await resolve(r.lng, r.lat); + if (!m.provinceName) continue; // outside VN polygon + if (m.provinceName === r.province) continue; + if (!dryRun) { + await prisma.$executeRawUnsafe( + `UPDATE "IndustrialPark" SET province = $2, district = COALESCE($3, district) WHERE id = $1`, + r.id, + m.provinceName, + m.districtName, + ); + } + updated++; + } + console.log(` ${updated}/${rows.length} rows would update.`); +} + +async function backfillProjectDevelopment(): Promise { + console.log('🏗️ ProjectDevelopment…'); + const rows = await prisma.$queryRawUnsafe< + { id: string; lat: number; lng: number; city: string; district: string; ward: string }[] + >( + `SELECT id, ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng, city, district, ward + FROM "ProjectDevelopment"`, + ); + let updated = 0; + for (const r of rows) { + const m = await resolve(r.lng, r.lat); + if (!m.provinceName) continue; + const sameCity = m.provinceName === r.city; + const sameDistrict = !m.districtName || m.districtName === r.district; + const sameWard = !m.wardName || m.wardName === r.ward; + if (sameCity && sameDistrict && sameWard) continue; + if (!dryRun) { + await prisma.$executeRawUnsafe( + `UPDATE "ProjectDevelopment" + SET city = $2, + district = COALESCE($3, district), + ward = COALESCE($4, ward) + WHERE id = $1`, + r.id, + m.provinceName, + m.districtName, + m.wardName, + ); + } + updated++; + } + console.log(` ${updated}/${rows.length} rows would update.`); +} + +async function backfillProperty(): Promise { + console.log('🏠 Property…'); + // Property has Vietnamese province / district / ward text columns; check schema. + const colsExist = await prisma.$queryRawUnsafe<{ count: bigint }[]>( + `SELECT COUNT(*)::bigint AS count + FROM information_schema.columns + WHERE table_name = 'Property' AND column_name = 'province'`, + ); + if (Number(colsExist[0]?.count ?? 0n) === 0) { + console.log(' (no province column on Property — skipping)'); + return; + } + const rows = await prisma.$queryRawUnsafe< + { id: string; lat: number; lng: number; province: string | null }[] + >( + `SELECT id, ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng, province + FROM "Property" + WHERE location IS NOT NULL`, + ); + let updated = 0; + for (const r of rows) { + const m = await resolve(r.lng, r.lat); + if (!m.provinceName) continue; + if (m.provinceName === r.province) continue; + if (!dryRun) { + await prisma.$executeRawUnsafe( + `UPDATE "Property" SET province = $2 WHERE id = $1`, + r.id, + m.provinceName, + ); + } + updated++; + } + console.log(` ${updated}/${rows.length} rows would update.`); +} + +async function main(): Promise { + console.log(`🌍 Admin-code backfill (dryRun=${dryRun})`); + + if (!tableArg || tableArg === 'industrial') await backfillIndustrialPark(); + if (!tableArg || tableArg === 'project') await backfillProjectDevelopment(); + if (!tableArg || tableArg === 'property') await backfillProperty(); +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + }); diff --git a/scripts/sync-osm-admin-boundaries.ts b/scripts/sync-osm-admin-boundaries.ts new file mode 100644 index 0000000..3d318fb --- /dev/null +++ b/scripts/sync-osm-admin-boundaries.ts @@ -0,0 +1,519 @@ +/** + * Sync Vietnam administrative boundaries from OpenStreetMap into the + * `vn_provinces` / `vn_districts` / `vn_wards` tables. + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/sync-osm-admin-boundaries.ts \ + * [--level=4|6|8|all] [--dry-run] [--chunk=NAME] + * + * What it does: + * 1. Queries Overpass for `boundary=administrative + admin_level=N` + * relations clipped to the Vietnam bbox (split into 4 chunks). + * 2. Converts each relation's outer rings into a MultiPolygon GeoJSON. + * 3. Looks up the GSO code from OSM tags (`ref:VN`, `gso_code`, + * `iso_code`, fallback to slugified name → existing seed row). + * 4. Upserts the row, writing geometry + centroid + areaKm2 + osmId. + * + * Coverage targets: + * admin_level=4 → 63 provinces (cities of central authority + 58 tỉnh) + * admin_level=6 → ~700 districts (quận / huyện / thị xã / TP thuộc tỉnh) + * admin_level=8 → ~11.000 wards (phường / xã / thị trấn) + * + * Notes: + * • Vietnam reformed wards in 2025 (some merged). We track historic + * names via `vn_administrative_aliases` — this script populates that + * table when an OSM tag `was:name` differs from the current name. + * • Wards (level 8) are the heaviest pull (~11k polygons). We always + * chunk them into 4 geographic slices to dodge Overpass timeouts. + */ +import 'dotenv/config'; +import area from '@turf/area'; +import centroid from '@turf/centroid'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '@prisma/client'; +import type { Feature, MultiPolygon, Polygon } from 'geojson'; +import osmtogeojson from 'osmtogeojson'; +import pg from 'pg'; +import { isPointInVietnam } from './data/vn-country-polygon'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const OVERPASS_URL = + process.env['OVERPASS_URL'] ?? 'https://overpass-api.de/api/interpreter'; + +interface BBox { + south: number; + west: number; + north: number; + east: number; +} + +/** Same chunks the KCN sync uses — keeps Overpass query budget reasonable. */ +const CHUNKS: Record = { + north: { south: 19.0, west: 102.0, north: 23.5, east: 110.0 }, + northCentral: { south: 16.5, west: 102.0, north: 19.0, east: 110.0 }, + southCentral: { south: 13.0, west: 102.0, north: 16.5, east: 110.0 }, + south: { south: 8.0, west: 102.0, north: 13.0, east: 110.0 }, +}; + +// ─── CLI ────────────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const dryRun = argv.includes('--dry-run'); +const chunkArg = argv.find((a) => a.startsWith('--chunk='))?.slice('--chunk='.length); +const levelArg = argv.find((a) => a.startsWith('--level='))?.slice('--level='.length) ?? 'all'; +const wantedLevels: number[] = + levelArg === 'all' + ? [4, 6, 8] + : levelArg + .split(',') + .map((s) => Number(s.trim())) + .filter((n) => [4, 6, 8].includes(n)); + +// ─── Slug helper (matches GSO codename style) ────────────────────────────── +function slugify(name: string): string { + return name + .toLowerCase() + .replace(/đ/g, 'd') + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); +} + +// ─── Overpass fetch ──────────────────────────────────────────────────────── +interface OverpassResult { + elements: unknown[]; +} + +async function fetchChunk(level: number, name: string, bbox: BBox): Promise { + // `out geom` returns the relation members with inline geometry so we can + // assemble polygons without a second roundtrip. Timeout 300s for level=8. + const query = ` + [out:json][timeout:300]; + relation + ["boundary"="administrative"] + ["admin_level"="${level}"] + (${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + out body geom; + `; + console.log(` → fetching level=${level} chunk="${name}"…`); + const start = Date.now(); + const res = await fetch(OVERPASS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'goodgo-osm-admin-sync/1.0 (https://goodgo.vn)', + }, + body: 'data=' + encodeURIComponent(query), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Overpass returned ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as OverpassResult; + console.log( + ` ← level=${level} ${name}: ${json.elements?.length ?? 0} relations in ${( + (Date.now() - start) / + 1000 + ).toFixed(1)}s`, + ); + return json; +} + +// ─── Per-feature parser ──────────────────────────────────────────────────── +interface ParsedAdmin { + level: 4 | 6 | 8; + osmId: bigint; + name: string; + nameEn: string | null; + gsoCode: string | null; + type: string; // "Tỉnh" / "Quận" / "Phường" etc. + geometry: MultiPolygon; // outer rings only + centroid: { lng: number; lat: number }; + areaKm2: number; + population: number | null; + rawTags: Record; +} + +const PROVINCE_TYPE_MAP = (name: string): string => + /^(Thành phố|TP\.?)\s+(Hà Nội|Hồ Chí Minh|Hải Phòng|Đà Nẵng|Cần Thơ)/i.test(name) + ? 'Thành phố Trung ương' + : 'Tỉnh'; + +const DISTRICT_TYPE_MAP = (name: string): string => { + if (/^Quận/i.test(name)) return 'Quận'; + if (/^Huyện/i.test(name)) return 'Huyện'; + if (/^Thị xã/i.test(name)) return 'Thị xã'; + if (/^Thành phố/i.test(name)) return 'Thành phố thuộc tỉnh'; + return 'Quận'; +}; + +const WARD_TYPE_MAP = (name: string): string => { + if (/^Phường/i.test(name)) return 'Phường'; + if (/^Xã/i.test(name)) return 'Xã'; + if (/^Thị trấn/i.test(name)) return 'Thị trấn'; + return 'Xã'; +}; + +function parseFeature( + feat: Feature, + level: 4 | 6 | 8, +): ParsedAdmin | null { + const propsRaw = feat.properties as Record | null; + if (!propsRaw) return null; + + // osmtogeojson encodes the prefixed id on `feat.id` ("relation/123") and + // the bare numeric id under `properties.id`. We only kept relations. + const featAny = feat as unknown as { id?: unknown }; + const idStr = String(featAny.id ?? propsRaw['id'] ?? ''); + if (!idStr.startsWith('relation/')) return null; + const osmId = BigInt(idStr.slice('relation/'.length)); + + const tagsRaw = propsRaw['tags']; + const tags: Record = + tagsRaw && typeof tagsRaw === 'object' + ? (tagsRaw as Record) + : (propsRaw as Record); + + const name = tags['name:vi'] ?? tags['name'] ?? null; + if (!name) return null; + // Skip rows without any Latin/Vietnamese letter (cross-border bleed). + if (!/[A-Za-zÀ-ỹ]/.test(name)) return null; + + const nameEn = tags['name:en'] ?? null; + const gsoCode = + tags['ref:VN'] ?? tags['gso_code'] ?? tags['ref'] ?? tags['iso_code'] ?? null; + const populationRaw = tags['population']; + const population = populationRaw && /^\d+$/.test(populationRaw) ? Number(populationRaw) : null; + + // Normalise to MultiPolygon regardless of source (Polygon → wrap once). + const geom: MultiPolygon = + feat.geometry.type === 'Polygon' + ? { type: 'MultiPolygon', coordinates: [feat.geometry.coordinates] } + : feat.geometry; + const c = centroid(feat as Feature); + const [cLng, cLat] = c.geometry.coordinates; + // Geographic gate: drop relations whose centroid sits outside the VN + // mainland polygon (China / Laos / Cambodia bleed across the bbox). + if (!isPointInVietnam(cLng, cLat)) return null; + const areaKm2 = Math.round((area(feat as Feature) / 1_000_000) * 100) / 100; + + let type: string; + if (level === 4) type = PROVINCE_TYPE_MAP(name); + else if (level === 6) type = DISTRICT_TYPE_MAP(name); + else type = WARD_TYPE_MAP(name); + + return { + level, + osmId, + name, + nameEn, + gsoCode, + type, + geometry: geom, + centroid: { lng: cLng, lat: cLat }, + areaKm2, + population, + rawTags: tags, + }; +} + +// ─── Resolve to existing GSO code or generate a synthetic one ───────────── +async function resolveProvinceCode(p: ParsedAdmin): Promise { + if (p.gsoCode) { + const exists = await prisma.vnProvince.findUnique({ where: { code: p.gsoCode } }); + if (exists) return p.gsoCode; + } + // Fallback: lookup by codename slug. + const codename = slugify(p.name); + const byCodename = await prisma.vnProvince.findFirst({ where: { codename } }); + if (byCodename) return byCodename.code; + // Brand-new: derive a code from osmId so it's stable. + return `OSM_${p.osmId.toString()}`; +} + +async function resolveDistrictCode(p: ParsedAdmin, provinceCode: string): Promise { + if (p.gsoCode) { + const exists = await prisma.vnDistrict.findUnique({ where: { code: p.gsoCode } }); + if (exists) return p.gsoCode; + } + const codename = slugify(p.name); + const byCodename = await prisma.vnDistrict.findFirst({ + where: { codename, provinceCode }, + }); + if (byCodename) return byCodename.code; + return `OSM_${p.osmId.toString()}`; +} + +async function resolveWardCode(p: ParsedAdmin, districtCode: string): Promise { + if (p.gsoCode) { + const exists = await prisma.vnWard.findUnique({ where: { code: p.gsoCode } }); + if (exists) return p.gsoCode; + } + const codename = slugify(p.name); + const byCodename = await prisma.vnWard.findFirst({ + where: { codename, districtCode }, + }); + if (byCodename) return byCodename.code; + return `OSM_${p.osmId.toString()}`; +} + +// ─── Upsert helpers — raw SQL because Prisma can't manage geometry ──────── +function geomSql(g: MultiPolygon): string { + const json = JSON.stringify(g).replace(/'/g, "''"); + return `ST_Multi(ST_GeomFromGeoJSON('${json}'))`; +} + +interface UpsertStats { + inserted: number; + updated: number; + skipped: number; +} + +async function upsertProvince(p: ParsedAdmin, stats: UpsertStats): Promise { + const code = await resolveProvinceCode(p); + const codename = slugify(p.name); + const existed = await prisma.vnProvince.findUnique({ where: { code }, select: { code: true } }); + + await prisma.$executeRawUnsafe( + ` + INSERT INTO "vn_provinces" ( + code, name, "nameEn", type, codename, "osmId", + "areaKm2", population, "lastSyncedAt", "updatedAt", geometry, centroid + ) VALUES ( + $1, $2, $3, $4, $5, $6::bigint, + $7, $8, NOW(), NOW(), + ${geomSql(p.geometry)}, + ST_SetSRID(ST_MakePoint($9, $10), 4326) + ) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + "nameEn" = EXCLUDED."nameEn", + type = EXCLUDED.type, + "osmId" = EXCLUDED."osmId", + "areaKm2" = EXCLUDED."areaKm2", + population = COALESCE(EXCLUDED.population, "vn_provinces".population), + "lastSyncedAt" = NOW(), + "updatedAt" = NOW(), + geometry = EXCLUDED.geometry, + centroid = EXCLUDED.centroid + `, + code, + p.name, + p.nameEn, + p.type, + codename, + p.osmId.toString(), + p.areaKm2, + p.population, + p.centroid.lng, + p.centroid.lat, + ); + if (existed) stats.updated++; + else stats.inserted++; +} + +async function upsertDistrict(p: ParsedAdmin, stats: UpsertStats): Promise { + // Find which province contains this district by ST_Within against existing + // synced province polygons. Falls back to province with largest overlap. + const provinceMatch = await prisma.$queryRawUnsafe<{ code: string }[]>( + `SELECT code FROM "vn_provinces" + WHERE geometry IS NOT NULL + AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1`, + p.centroid.lng, + p.centroid.lat, + ); + if (provinceMatch.length === 0) { + stats.skipped++; + return; // Cannot place district until provinces are synced first. + } + const provinceCode = provinceMatch[0]!.code; + const code = await resolveDistrictCode(p, provinceCode); + const codename = slugify(p.name); + const existed = await prisma.vnDistrict.findUnique({ where: { code }, select: { code: true } }); + + await prisma.$executeRawUnsafe( + ` + INSERT INTO "vn_districts" ( + code, "provinceCode", name, "nameEn", type, codename, "osmId", + "areaKm2", population, "lastSyncedAt", "updatedAt", geometry, centroid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7::bigint, + $8, $9, NOW(), NOW(), + ${geomSql(p.geometry)}, + ST_SetSRID(ST_MakePoint($10, $11), 4326) + ) + ON CONFLICT (code) DO UPDATE SET + "provinceCode" = EXCLUDED."provinceCode", + name = EXCLUDED.name, + "nameEn" = EXCLUDED."nameEn", + type = EXCLUDED.type, + "osmId" = EXCLUDED."osmId", + "areaKm2" = EXCLUDED."areaKm2", + population = COALESCE(EXCLUDED.population, "vn_districts".population), + "lastSyncedAt" = NOW(), + "updatedAt" = NOW(), + geometry = EXCLUDED.geometry, + centroid = EXCLUDED.centroid + `, + code, + provinceCode, + p.name, + p.nameEn, + p.type, + codename, + p.osmId.toString(), + p.areaKm2, + p.population, + p.centroid.lng, + p.centroid.lat, + ); + if (existed) stats.updated++; + else stats.inserted++; +} + +async function upsertWard(p: ParsedAdmin, stats: UpsertStats): Promise { + const districtMatch = await prisma.$queryRawUnsafe<{ code: string }[]>( + `SELECT code FROM "vn_districts" + WHERE geometry IS NOT NULL + AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1`, + p.centroid.lng, + p.centroid.lat, + ); + if (districtMatch.length === 0) { + stats.skipped++; + return; + } + const districtCode = districtMatch[0]!.code; + const code = await resolveWardCode(p, districtCode); + const codename = slugify(p.name); + const existed = await prisma.vnWard.findUnique({ where: { code }, select: { code: true } }); + + await prisma.$executeRawUnsafe( + ` + INSERT INTO "vn_wards" ( + code, "districtCode", name, "nameEn", type, codename, "osmId", + "areaKm2", population, "lastSyncedAt", "updatedAt", geometry, centroid + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7::bigint, + $8, $9, NOW(), NOW(), + ${geomSql(p.geometry)}, + ST_SetSRID(ST_MakePoint($10, $11), 4326) + ) + ON CONFLICT (code) DO UPDATE SET + "districtCode" = EXCLUDED."districtCode", + name = EXCLUDED.name, + "nameEn" = EXCLUDED."nameEn", + type = EXCLUDED.type, + "osmId" = EXCLUDED."osmId", + "areaKm2" = EXCLUDED."areaKm2", + population = COALESCE(EXCLUDED.population, "vn_wards".population), + "lastSyncedAt" = NOW(), + "updatedAt" = NOW(), + geometry = EXCLUDED.geometry, + centroid = EXCLUDED.centroid + `, + code, + districtCode, + p.name, + p.nameEn, + p.type, + codename, + p.osmId.toString(), + p.areaKm2, + p.population, + p.centroid.lng, + p.centroid.lat, + ); + if (existed) stats.updated++; + else stats.inserted++; +} + +// ─── Main ───────────────────────────────────────────────────────────────── +async function processChunk( + level: 4 | 6 | 8, + chunkName: string, + bbox: BBox, +): Promise { + const stats: UpsertStats = { inserted: 0, updated: 0, skipped: 0 }; + const result = await fetchChunk(level, chunkName, bbox); + const fc = osmtogeojson(result, { flatProperties: false }); + const features = (fc.features as Feature[]).filter( + (f) => f.geometry?.type === 'Polygon' || f.geometry?.type === 'MultiPolygon', + ); + + for (const feat of features) { + const parsed = parseFeature(feat, level); + if (!parsed) continue; + if (dryRun) { + stats.inserted++; + continue; + } + try { + if (level === 4) await upsertProvince(parsed, stats); + else if (level === 6) await upsertDistrict(parsed, stats); + else await upsertWard(parsed, stats); + } catch (err) { + console.error(` ✗ ${parsed.name}: ${err instanceof Error ? err.message : err}`); + stats.skipped++; + } + } + console.log( + ` ✓ level=${level} ${chunkName}: inserted=${stats.inserted} updated=${stats.updated} skipped=${stats.skipped}`, + ); + return stats; +} + +async function main(): Promise { + console.log('🌏 OSM admin boundaries sync starting'); + console.log(` levels: ${wantedLevels.join(',')}, chunks: ${chunkArg ?? 'all'}, dryRun=${dryRun}`); + + const chunks = chunkArg + ? { [chunkArg]: CHUNKS[chunkArg]! } + : CHUNKS; + + const totals: Record = { + 4: { inserted: 0, updated: 0, skipped: 0 }, + 6: { inserted: 0, updated: 0, skipped: 0 }, + 8: { inserted: 0, updated: 0, skipped: 0 }, + }; + + // ALWAYS process levels in order 4 → 6 → 8, because 6 needs province + // polygons in the DB to assign provinceCode (and 8 needs districts). + for (const level of wantedLevels.sort() as (4 | 6 | 8)[]) { + console.log(`\n=== Level ${level} ===`); + for (const [name, bbox] of Object.entries(chunks)) { + try { + const s = await processChunk(level, name, bbox); + totals[level]!.inserted += s.inserted; + totals[level]!.updated += s.updated; + totals[level]!.skipped += s.skipped; + } catch (err) { + console.error(` ✗ chunk ${name} (level ${level}) failed:`, err); + } + } + } + + console.log('\n📊 Totals'); + for (const lvl of wantedLevels) { + const t = totals[lvl]!; + console.log( + ` level=${lvl}: inserted=${t.inserted} updated=${t.updated} skipped=${t.skipped}`, + ); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + }); diff --git a/scripts/sync-osm-industrial-parks.ts b/scripts/sync-osm-industrial-parks.ts index 8066d07..1d7ac4b 100644 --- a/scripts/sync-osm-industrial-parks.ts +++ b/scripts/sync-osm-industrial-parks.ts @@ -215,9 +215,11 @@ function parseFeature( if (!isPointInVietnam(cLng, cLat)) return null; // Province resolution: prefer explicit OSM tags, then fall back to a - // nearest-centroid lookup against our 63-province table. The fallback - // catches the (very common) case where Vietnamese landuse polygons have - // no addr:* tags at all. + // nearest-centroid lookup against our 63-province table. The actual DB + // upsert step (`upsertFeature`) replaces this with a precise PostGIS + // ST_Contains lookup against `vn_provinces.geometry` once those polygons + // are synced — this is just the bootstrap value used when the polygon + // table is empty. const province = VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ?? tags['addr:city'] ?? @@ -271,6 +273,33 @@ async function upsertFeature( return; } + // Override the heuristic province with a precise PostGIS lookup against + // the OSM-sourced admin polygons (when synced). Falls back to the + // nearest-centroid value already on `parsed.province` if the polygon + // table doesn't yet cover that area. + const adminMatch = await prisma.$queryRawUnsafe< + { provinceName: string | null; districtName: string | null }[] + >( + `WITH p AS ( + SELECT code, name FROM "vn_provinces" + WHERE geometry IS NOT NULL + AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1 + ) + SELECT + (SELECT name FROM p) AS "provinceName", + (SELECT d.name FROM "vn_districts" d JOIN p ON p.code = d."provinceCode" + WHERE d.geometry IS NOT NULL + AND ST_Contains(d.geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1) AS "districtName"`, + parsed.centroid.lng, + parsed.centroid.lat, + ); + const resolvedProvince = adminMatch[0]?.provinceName ?? parsed.province; + const resolvedDistrict = adminMatch[0]?.districtName ?? parsed.district; + parsed.province = resolvedProvince; + if (!parsed.district) parsed.district = resolvedDistrict ?? ''; + const region = guessRegion(parsed.centroid.lat); const slug = slugify(parsed.name, parsed.osmId.toString()); diff --git a/scripts/sync-osm-poi.ts b/scripts/sync-osm-poi.ts new file mode 100644 index 0000000..31fae6d --- /dev/null +++ b/scripts/sync-osm-poi.ts @@ -0,0 +1,400 @@ +/** + * Sync OSM points-of-interest into the `Poi` table. + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/sync-osm-poi.ts \ + * [--category=school,hospital,...|all] [--chunk=NAME] [--dry-run] + * + * What it does: + * 1. For each requested category, queries Overpass for the matching + * node/way/relation across the 4 Vietnam chunks. + * 2. Filters out non-Vietnam centroids (cross-border bleed) and rows + * without any Latin/Vietnamese letters in the name. + * 3. Resolves provinceCode/districtCode/wardCode via PostGIS lookup + * against `vn_provinces` / `vn_districts` / `vn_wards` (assumes + * Phase 0 boundary sync ran first). + * 4. Upserts on `osmId`, honouring `osmLocked` + `lockedFields`. + */ +import 'dotenv/config'; +import area from '@turf/area'; +import centroid from '@turf/centroid'; +import { createId } from '@paralleldrive/cuid2'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { type Prisma, PrismaClient } from '@prisma/client'; +import type { Feature, MultiPolygon, Polygon, Point } from 'geojson'; +import osmtogeojson from 'osmtogeojson'; +import pg from 'pg'; +import { isPointInVietnam } from './data/vn-country-polygon'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const OVERPASS_URL = + process.env['OVERPASS_URL'] ?? 'https://overpass-api.de/api/interpreter'; + +interface BBox { + south: number; + west: number; + north: number; + east: number; +} + +const CHUNKS: Record = { + north: { south: 19.0, west: 102.0, north: 23.5, east: 110.0 }, + northCentral: { south: 16.5, west: 102.0, north: 19.0, east: 110.0 }, + southCentral: { south: 13.0, west: 102.0, north: 16.5, east: 110.0 }, + south: { south: 8.0, west: 102.0, north: 13.0, east: 110.0 }, +}; + +type PoiCategoryKey = + | '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'; + +/** + * For each category, the Overpass selector. We query node/way/relation + * to catch both single points and named building polygons. + */ +const CATEGORY_QUERIES: Record = { + // ── Education ───────────────────────────────────────────────────────── + SCHOOL_PRIMARY: '["amenity"="school"]["isced:level"~"^(primary|0|1)$"]', + SCHOOL_SECONDARY: '["amenity"="school"]["isced:level"~"^(secondary|2|3)$"]', + UNIVERSITY: '["amenity"~"^(university|college)$"]', + // ── Health ──────────────────────────────────────────────────────────── + HOSPITAL: '["amenity"="hospital"]', + CLINIC: '["amenity"="clinic"]', + PHARMACY: '["amenity"="pharmacy"]', + // ── Commerce ────────────────────────────────────────────────────────── + MARKET: '["amenity"="marketplace"]', + SUPERMARKET: '["shop"="supermarket"]', + MALL: '["shop"="mall"]', + CONVENIENCE: '["shop"="convenience"]', + // ── Finance ─────────────────────────────────────────────────────────── + BANK: '["amenity"="bank"]', + ATM: '["amenity"="atm"]', + // ── Recreation / Services ──────────────────────────────────────────── + PARK: '["leisure"="park"]', + GAS_STATION: '["amenity"="fuel"]', + POLICE: '["amenity"="police"]', + POST_OFFICE: '["amenity"="post_office"]', + // ── Transport (stations / airports — lines live in TransportLine) ──── + METRO_STATION: '["railway"="station"]["station"="subway"]', + RAILWAY_STATION: '["railway"="station"]["station"!="subway"]', + BUS_STATION: '["amenity"="bus_station"]', + AIRPORT: '["aeroway"="aerodrome"]["aerodrome:type"~"international|public"]', +}; + +const ALL_CATEGORIES: PoiCategoryKey[] = Object.keys(CATEGORY_QUERIES) as PoiCategoryKey[]; + +// ─── CLI ─────────────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const dryRun = argv.includes('--dry-run'); +const chunkArg = argv.find((a) => a.startsWith('--chunk='))?.slice('--chunk='.length); +const categoryArg = argv.find((a) => a.startsWith('--category='))?.slice('--category='.length) ?? 'all'; +const wantedCategories: PoiCategoryKey[] = + categoryArg === 'all' + ? ALL_CATEGORIES + : (categoryArg + .split(',') + .map((s) => s.trim().toUpperCase()) + .filter((s): s is PoiCategoryKey => ALL_CATEGORIES.includes(s as PoiCategoryKey)) as PoiCategoryKey[]); + +if (wantedCategories.length === 0) { + console.error(`No valid categories. Available: ${ALL_CATEGORIES.join(', ')}`); + process.exit(1); +} + +interface OverpassResult { + elements: unknown[]; +} + +async function fetchChunk( + category: PoiCategoryKey, + chunkName: string, + bbox: BBox, +): Promise { + const sel = CATEGORY_QUERIES[category]; + const query = ` + [out:json][timeout:180]; + ( + node${sel}(${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + way${sel}(${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + relation${sel}(${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + ); + out body geom; + `; + const t0 = Date.now(); + console.log(` → ${category} ${chunkName}…`); + const res = await fetch(OVERPASS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'goodgo-osm-poi-sync/1.0 (https://goodgo.vn)', + }, + body: 'data=' + encodeURIComponent(query), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Overpass ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as OverpassResult; + console.log( + ` ← ${category} ${chunkName}: ${json.elements?.length ?? 0} elements in ${( + (Date.now() - t0) / + 1000 + ).toFixed(1)}s`, + ); + return json; +} + +interface ParsedPoi { + category: PoiCategoryKey; + osmId: bigint; + osmType: 'NODE' | 'WAY' | 'RELATION'; + name: string; + nameEn: string | null; + centroid: { lng: number; lat: number }; + address: string | null; + tags: Record; +} + +function parseFeature( + feat: Feature, + category: PoiCategoryKey, +): ParsedPoi | null { + const featAny = feat as unknown as { id?: unknown }; + const idStr = String(featAny.id ?? ''); + const slashIdx = idStr.indexOf('/'); + if (slashIdx < 0) return null; + const typeStr = idStr.slice(0, slashIdx).toUpperCase(); + if (typeStr !== 'NODE' && typeStr !== 'WAY' && typeStr !== 'RELATION') return null; + const osmType = typeStr as 'NODE' | 'WAY' | 'RELATION'; + const osmId = BigInt(idStr.slice(slashIdx + 1)); + + const propsRaw = (feat.properties ?? {}) as Record; + const tagsRaw = propsRaw['tags']; + const tags: Record = + tagsRaw && typeof tagsRaw === 'object' + ? (tagsRaw as Record) + : (propsRaw as Record); + + const name = tags['name:vi'] ?? tags['name'] ?? null; + // Skip purely unnamed POIs (very common for shop=convenience etc.) + if (!name) return null; + // Skip rows without Latin/Vietnamese letters (cross-border bleed). + if (!/[A-Za-zÀ-ỹ]/.test(name)) return null; + + let cLng: number; + let cLat: number; + if (feat.geometry.type === 'Point') { + [cLng, cLat] = feat.geometry.coordinates; + } else { + const c = centroid(feat as Feature); + [cLng, cLat] = c.geometry.coordinates; + } + if (!isPointInVietnam(cLng, cLat)) return null; + + const address = + tags['addr:full'] ?? + [tags['addr:housenumber'], tags['addr:street']].filter(Boolean).join(' ') ?? + null; + + return { + category, + osmId, + osmType, + name, + nameEn: tags['name:en'] ?? null, + centroid: { lng: cLng, lat: cLat }, + address: address || null, + tags, + }; +} + +interface UpsertStats { + inserted: number; + updated: number; + locked: number; + skipped: number; +} + +async function upsertPoi(parsed: ParsedPoi, stats: UpsertStats): Promise { + const existing = await prisma.poi.findUnique({ + where: { osmId: parsed.osmId }, + select: { id: true, osmLocked: true, lockedFields: true }, + }); + if (existing?.osmLocked) { + stats.locked++; + return; + } + + // Resolve admin codes from the polygon tables. + const admin = await prisma.$queryRawUnsafe< + { provinceCode: string | null; districtCode: string | null; wardCode: string | null }[] + >( + `WITH p AS ( + SELECT code FROM "vn_provinces" + WHERE geometry IS NOT NULL + AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1 + ), + d AS ( + SELECT d.code + FROM "vn_districts" d JOIN p ON p.code = d."provinceCode" + WHERE d.geometry IS NOT NULL + AND ST_Contains(d.geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1 + ) + SELECT + (SELECT code FROM p) AS "provinceCode", + (SELECT code FROM d) AS "districtCode", + (SELECT w.code FROM "vn_wards" w JOIN d ON d.code = w."districtCode" + WHERE w.geometry IS NOT NULL + AND ST_Contains(w.geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326)) + LIMIT 1) AS "wardCode"`, + parsed.centroid.lng, + parsed.centroid.lat, + ); + const provinceCode = admin[0]?.provinceCode ?? null; + const districtCode = admin[0]?.districtCode ?? null; + const wardCode = admin[0]?.wardCode ?? null; + + if (!existing) { + const cuid = createId(); + await prisma.$executeRawUnsafe( + ` + INSERT INTO "Poi" ( + id, category, name, "nameEn", location, address, + "provinceCode", "districtCode", "wardCode", + "osmId", "osmType", "osmTags", + "dataSource", "isPublic", "lastSyncedAt", "createdAt", "updatedAt" + ) VALUES ( + $1, $2::"PoiCategory", $3, $4, + ST_SetSRID(ST_MakePoint($5, $6), 4326), $7, + $8, $9, $10, + $11::bigint, $12::"OsmType", $13::jsonb, + 'OSM'::"OsmDataSource", true, NOW(), NOW(), NOW() + ) + `, + cuid, + parsed.category, + parsed.name, + parsed.nameEn, + parsed.centroid.lng, + parsed.centroid.lat, + parsed.address, + provinceCode, + districtCode, + wardCode, + parsed.osmId.toString(), + parsed.osmType, + JSON.stringify(parsed.tags), + ); + stats.inserted++; + } else { + // Update — respect lockedFields list. + const locked = new Set(existing.lockedFields ?? []); + const data: Prisma.PoiUpdateInput = { + lastSyncedAt: new Date(), + osmTags: JSON.stringify(parsed.tags) as unknown as Prisma.InputJsonValue, + }; + if (!locked.has('name')) data.name = parsed.name; + if (!locked.has('nameEn')) data.nameEn = parsed.nameEn; + if (!locked.has('address')) data.address = parsed.address; + if (!locked.has('provinceCode')) data.provinceCode = provinceCode; + if (!locked.has('districtCode')) data.districtCode = districtCode; + if (!locked.has('wardCode')) data.wardCode = wardCode; + await prisma.poi.update({ where: { id: existing.id }, data }); + // Location update via raw SQL (Prisma can't write `Unsupported` columns). + if (!locked.has('location')) { + await prisma.$executeRawUnsafe( + `UPDATE "Poi" SET location = ST_SetSRID(ST_MakePoint($1, $2), 4326) WHERE id = $3`, + parsed.centroid.lng, + parsed.centroid.lat, + existing.id, + ); + } + stats.updated++; + } +} + +async function processCategoryChunk( + category: PoiCategoryKey, + chunkName: string, + bbox: BBox, + stats: UpsertStats, +): Promise { + const result = await fetchChunk(category, chunkName, bbox); + const fc = osmtogeojson(result, { flatProperties: false }); + const features = (fc.features as Feature[]).filter( + (f) => + f.geometry?.type === 'Point' || + f.geometry?.type === 'Polygon' || + f.geometry?.type === 'MultiPolygon', + ); + for (const feat of features) { + const parsed = parseFeature(feat, category); + if (!parsed) continue; + if (dryRun) { + stats.inserted++; + continue; + } + try { + await upsertPoi(parsed, stats); + } catch (err) { + console.error( + ` ✗ ${category} ${parsed.name}: ${err instanceof Error ? err.message : err}`, + ); + stats.skipped++; + } + } +} + +async function main(): Promise { + console.log(`📍 OSM POI sync: categories=${wantedCategories.join(',')} dryRun=${dryRun}`); + + const chunks = chunkArg + ? { [chunkArg]: CHUNKS[chunkArg]! } + : CHUNKS; + + const totals: Record = {}; + for (const cat of wantedCategories) { + console.log(`\n=== ${cat} ===`); + const s: UpsertStats = { inserted: 0, updated: 0, locked: 0, skipped: 0 }; + for (const [name, bbox] of Object.entries(chunks)) { + try { + await processCategoryChunk(cat, name, bbox, s); + } catch (err) { + console.error(` ✗ chunk ${name} (${cat}) failed:`, err); + } + } + totals[cat] = s; + console.log( + ` ✓ ${cat}: inserted=${s.inserted} updated=${s.updated} locked=${s.locked} skipped=${s.skipped}`, + ); + } + + console.log('\n📊 Totals:'); + for (const cat of wantedCategories) { + const s = totals[cat]!; + console.log( + ` ${cat.padEnd(20)} inserted=${s.inserted} updated=${s.updated} locked=${s.locked} skipped=${s.skipped}`, + ); + } +} + +main() + .catch((err) => { + console.error(err); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + });