feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator
This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.
Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
`boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
filters to mainland VN via the existing country polygon, resolves the
GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
`nearestProvince()` heuristic — `lookup(lng,lat)` returns
province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
and falls back to the centroid heuristic only when polygons aren't
loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
IndustrialPark, ProjectDevelopment and Property using the new lookup.
Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
GIST index on `location`. New `TransportLine` for metro/highway
multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
resolves province/district codes from the boundary polygons, upserts
with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
GET /poi/by-bbox — GeoJSON for map overlays
GET /poi/nearby — sidebar "tiện ích xung quanh" (HMAC distance ranks)
GET /poi/coverage — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
project / KCN detail pages.
Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
(RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
Daily 02:00 → POI category rotation (1/day, 20-day cycle)
Mon 02:30 → admin-boundaries provinces
Wed 02:30 → admin-boundaries districts
Sat 02:30 → admin-boundaries wards
1st of month 03:00 → industrial-parks (per chunk)
All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
per-row "Sync now", recent runs list with auto-refresh every 15s.
Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,9 @@ import { McpIntegrationModule } from '@modules/mcp';
|
|||||||
import { MessagingModule } from '@modules/messaging';
|
import { MessagingModule } from '@modules/messaging';
|
||||||
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
import { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||||
import { NotificationsModule } from '@modules/notifications';
|
import { NotificationsModule } from '@modules/notifications';
|
||||||
|
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
|
||||||
import { PaymentsModule } from '@modules/payments';
|
import { PaymentsModule } from '@modules/payments';
|
||||||
|
import { PoiModule } from '@modules/poi/poi.module';
|
||||||
import { ProjectsModule } from '@modules/projects';
|
import { ProjectsModule } from '@modules/projects';
|
||||||
import { QueuesModule } from '@modules/queues/queues.module';
|
import { QueuesModule } from '@modules/queues/queues.module';
|
||||||
import { ReportsModule } from '@modules/reports';
|
import { ReportsModule } from '@modules/reports';
|
||||||
@@ -58,7 +60,9 @@ import { AppController } from './app.controller';
|
|||||||
FavoritesModule,
|
FavoritesModule,
|
||||||
SearchModule,
|
SearchModule,
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
|
OsmSyncModule,
|
||||||
PaymentsModule,
|
PaymentsModule,
|
||||||
|
PoiModule,
|
||||||
SubscriptionsModule,
|
SubscriptionsModule,
|
||||||
AdminModule,
|
AdminModule,
|
||||||
AnalyticsModule,
|
AnalyticsModule,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<TriggerOsmSyncCommand> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<OsmCoverageSummaryQuery, OsmCoverageSummary>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly geo: GeoLookupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(): Promise<OsmCoverageSummary> {
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
/** Aggregate coverage view across all OSM-managed tables for the
|
||||||
|
* admin dashboard. */
|
||||||
|
export class OsmCoverageSummaryQuery {}
|
||||||
@@ -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<ListOsmSyncRunsQuery, OsmSyncRun[]>
|
||||||
|
{
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async execute(q: ListOsmSyncRunsQuery): Promise<OsmSyncRun[]> {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
239
apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts
Normal file
239
apps/api/src/modules/osm-sync/infrastructure/osm-sync.service.ts
Normal file
@@ -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<OsmSyncLayerDef>((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<OsmSyncStatus> {
|
||||||
|
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<void> {
|
||||||
|
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',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
apps/api/src/modules/osm-sync/osm-sync.module.ts
Normal file
22
apps/api/src/modules/osm-sync/osm-sync.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -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),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<PoiCoverageStatsQuery, PoiCoverageRow[]>
|
||||||
|
{
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async execute(q: PoiCoverageStatsQuery): Promise<PoiCoverageRow[]> {
|
||||||
|
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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
}
|
||||||
@@ -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<string, NearbyPoi[]>;
|
||||||
|
/** 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<FindNearbyPoiQuery, NearbyPoiResult>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(q: FindNearbyPoiQuery): Promise<NearbyPoiResult> {
|
||||||
|
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<string, NearbyPoi[]> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -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<ListPoiByBboxQuery, PoiGeoCollection>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(q: ListPoiByBboxQuery): Promise<PoiGeoCollection> {
|
||||||
|
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<BboxRow[]>(
|
||||||
|
`
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
19
apps/api/src/modules/poi/poi.module.ts
Normal file
19
apps/api/src/modules/poi/poi.module.ts
Normal file
@@ -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 {}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
184
apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts
Normal file
184
apps/api/src/modules/shared/infrastructure/geo-lookup.service.ts
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { LoggerService } from './logger.service';
|
||||||
|
import { PrismaService } from './prisma.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a "where am I?" geo lookup. Each level may be null when the
|
||||||
|
* point lies outside any synced polygon (or when that level hasn't been
|
||||||
|
* synced yet — see PHASE_0 in the OSM rollout plan).
|
||||||
|
*/
|
||||||
|
export interface GeoLookupResult {
|
||||||
|
province: { code: string; name: string } | null;
|
||||||
|
district: { code: string; name: string } | null;
|
||||||
|
ward: { code: string; name: string } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralised "lat/lng → administrative unit" resolver. Replaces the old
|
||||||
|
* `nearestProvince()` helper that walked a hard-coded centroid table —
|
||||||
|
* we now use real OSM-sourced polygons (PostGIS `ST_Contains`).
|
||||||
|
*
|
||||||
|
* Backed by the `vn_provinces` / `vn_districts` / `vn_wards` tables that
|
||||||
|
* `scripts/sync-osm-admin-boundaries.ts` populates. All three GIST-indexed
|
||||||
|
* geometry columns mean each lookup is O(log N).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class GeoLookupService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a point to the deepest administrative unit available. Returns
|
||||||
|
* partial results when the polygon hierarchy is incomplete (e.g. ward
|
||||||
|
* polygons not synced yet for that area).
|
||||||
|
*/
|
||||||
|
async lookup(lng: number, lat: number): Promise<GeoLookupResult> {
|
||||||
|
if (!this.isFiniteCoord(lng, lat)) {
|
||||||
|
return { province: null, district: null, ward: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Province first — fastest GIST lookup, parents the other two.
|
||||||
|
const provinceRows = await this.prisma.$queryRawUnsafe<
|
||||||
|
{ code: string; name: string }[]
|
||||||
|
>(
|
||||||
|
`SELECT code, name FROM "vn_provinces"
|
||||||
|
WHERE geometry IS NOT NULL
|
||||||
|
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
||||||
|
LIMIT 1`,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
);
|
||||||
|
const province = provinceRows[0] ?? null;
|
||||||
|
if (!province) return { province: null, district: null, ward: null };
|
||||||
|
|
||||||
|
// District scoped to the matched province for speed + correctness
|
||||||
|
// around shared borders.
|
||||||
|
const districtRows = await this.prisma.$queryRawUnsafe<
|
||||||
|
{ code: string; name: string }[]
|
||||||
|
>(
|
||||||
|
`SELECT code, name FROM "vn_districts"
|
||||||
|
WHERE "provinceCode" = $3
|
||||||
|
AND geometry IS NOT NULL
|
||||||
|
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
||||||
|
LIMIT 1`,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
province.code,
|
||||||
|
);
|
||||||
|
const district = districtRows[0] ?? null;
|
||||||
|
if (!district) {
|
||||||
|
return { province, district: null, ward: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const wardRows = await this.prisma.$queryRawUnsafe<
|
||||||
|
{ code: string; name: string }[]
|
||||||
|
>(
|
||||||
|
`SELECT code, name FROM "vn_wards"
|
||||||
|
WHERE "districtCode" = $3
|
||||||
|
AND geometry IS NOT NULL
|
||||||
|
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
||||||
|
LIMIT 1`,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
district.code,
|
||||||
|
);
|
||||||
|
const ward = wardRows[0] ?? null;
|
||||||
|
|
||||||
|
return { province, district, ward };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convenience wrapper that returns just the province display name. */
|
||||||
|
async findProvinceName(lng: number, lat: number): Promise<string | null> {
|
||||||
|
const r = await this.lookup(lng, lat);
|
||||||
|
return r.province?.name ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if any province polygon contains the point — i.e. point is in VN. */
|
||||||
|
async isInVietnam(lng: number, lat: number): Promise<boolean> {
|
||||||
|
if (!this.isFiniteCoord(lng, lat)) return false;
|
||||||
|
const rows = await this.prisma.$queryRawUnsafe<{ exists: boolean }[]>(
|
||||||
|
`SELECT EXISTS (
|
||||||
|
SELECT 1 FROM "vn_provinces"
|
||||||
|
WHERE geometry IS NOT NULL
|
||||||
|
AND ST_Contains(geometry, ST_SetSRID(ST_MakePoint($1, $2), 4326))
|
||||||
|
) AS exists`,
|
||||||
|
lng,
|
||||||
|
lat,
|
||||||
|
);
|
||||||
|
return rows[0]?.exists ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Coverage report for the admin dashboard: how many polygons of each
|
||||||
|
* level we have, and when each was last refreshed. Cheap aggregate.
|
||||||
|
*/
|
||||||
|
async coverage(): Promise<{
|
||||||
|
provinces: { total: number; withGeometry: number; lastSyncedAt: Date | null };
|
||||||
|
districts: { total: number; withGeometry: number; lastSyncedAt: Date | null };
|
||||||
|
wards: { total: number; withGeometry: number; lastSyncedAt: Date | null };
|
||||||
|
}> {
|
||||||
|
const [p, d, w] = await Promise.all([
|
||||||
|
this.prisma.$queryRawUnsafe<
|
||||||
|
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT COUNT(*)::bigint AS total,
|
||||||
|
COUNT(geometry)::bigint AS "withGeometry",
|
||||||
|
MAX("lastSyncedAt") AS "lastSyncedAt"
|
||||||
|
FROM "vn_provinces"`,
|
||||||
|
),
|
||||||
|
this.prisma.$queryRawUnsafe<
|
||||||
|
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT COUNT(*)::bigint AS total,
|
||||||
|
COUNT(geometry)::bigint AS "withGeometry",
|
||||||
|
MAX("lastSyncedAt") AS "lastSyncedAt"
|
||||||
|
FROM "vn_districts"`,
|
||||||
|
),
|
||||||
|
this.prisma.$queryRawUnsafe<
|
||||||
|
{ total: bigint; withGeometry: bigint; lastSyncedAt: Date | null }[]
|
||||||
|
>(
|
||||||
|
`SELECT COUNT(*)::bigint AS total,
|
||||||
|
COUNT(geometry)::bigint AS "withGeometry",
|
||||||
|
MAX("lastSyncedAt") AS "lastSyncedAt"
|
||||||
|
FROM "vn_wards"`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
provinces: {
|
||||||
|
total: Number(p[0]?.total ?? 0n),
|
||||||
|
withGeometry: Number(p[0]?.withGeometry ?? 0n),
|
||||||
|
lastSyncedAt: p[0]?.lastSyncedAt ?? null,
|
||||||
|
},
|
||||||
|
districts: {
|
||||||
|
total: Number(d[0]?.total ?? 0n),
|
||||||
|
withGeometry: Number(d[0]?.withGeometry ?? 0n),
|
||||||
|
lastSyncedAt: d[0]?.lastSyncedAt ?? null,
|
||||||
|
},
|
||||||
|
wards: {
|
||||||
|
total: Number(w[0]?.total ?? 0n),
|
||||||
|
withGeometry: Number(w[0]?.withGeometry ?? 0n),
|
||||||
|
lastSyncedAt: w[0]?.lastSyncedAt ?? null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private isFiniteCoord(lng: number, lat: number): boolean {
|
||||||
|
if (!Number.isFinite(lng) || !Number.isFinite(lat)) {
|
||||||
|
this.logger.warn(
|
||||||
|
`GeoLookupService: invalid coordinates lng=${lng} lat=${lat}`,
|
||||||
|
'GeoLookupService',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (lng < -180 || lng > 180 || lat < -90 || lat > 90) {
|
||||||
|
this.logger.warn(
|
||||||
|
`GeoLookupService: out-of-range coordinates lng=${lng} lat=${lat}`,
|
||||||
|
'GeoLookupService',
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
type ModelEncryptionFieldConfig,
|
type ModelEncryptionFieldConfig,
|
||||||
} from './field-encryption.service';
|
} from './field-encryption.service';
|
||||||
export { createEncryptionExtension } from './encryption-middleware';
|
export { createEncryptionExtension } from './encryption-middleware';
|
||||||
|
export { GeoLookupService, type GeoLookupResult } from './geo-lookup.service';
|
||||||
export { PrismaService } from './prisma.service';
|
export { PrismaService } from './prisma.service';
|
||||||
export { RedisService } from './redis.service';
|
export { RedisService } from './redis.service';
|
||||||
export { RedisIoAdapter } from './redis-io.adapter';
|
export { RedisIoAdapter } from './redis-io.adapter';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
// import { EVENT_BUS, RedisStreamsEventBus } from './infrastructure/event-bus';
|
// import { EVENT_BUS, RedisStreamsEventBus } from './infrastructure/event-bus';
|
||||||
import { EventBusService } from './infrastructure/event-bus.service';
|
import { EventBusService } from './infrastructure/event-bus.service';
|
||||||
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
||||||
|
import { GeoLookupService } from './infrastructure/geo-lookup.service';
|
||||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||||
import { DeprecationInterceptor, VersionInterceptor } from './infrastructure/interceptors';
|
import { DeprecationInterceptor, VersionInterceptor } from './infrastructure/interceptors';
|
||||||
import { LoggerService } from './infrastructure/logger.service';
|
import { LoggerService } from './infrastructure/logger.service';
|
||||||
@@ -43,6 +44,7 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
|
|||||||
RedisService,
|
RedisService,
|
||||||
CacheService,
|
CacheService,
|
||||||
EventBusService,
|
EventBusService,
|
||||||
|
GeoLookupService,
|
||||||
// RFC-004 Phase 0 (GOO-172) — see import comment above.
|
// RFC-004 Phase 0 (GOO-172) — see import comment above.
|
||||||
// { provide: EVENT_BUS, useClass: RedisStreamsEventBus },
|
// { provide: EVENT_BUS, useClass: RedisStreamsEventBus },
|
||||||
// OutboxService,
|
// OutboxService,
|
||||||
@@ -78,7 +80,17 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
|
|||||||
useClass: DeprecationInterceptor,
|
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 {
|
export class SharedModule implements NestModule {
|
||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
|
|||||||
321
apps/web/app/[locale]/(admin)/admin/osm/page.tsx
Normal file
321
apps/web/app/[locale]/(admin)/admin/osm/page.tsx
Normal file
@@ -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<OsmSyncRun['status'], string> = {
|
||||||
|
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<OsmSyncRun['status'], React.ReactNode> = {
|
||||||
|
RUNNING: <RefreshCw className="h-3 w-3 animate-spin" />,
|
||||||
|
SUCCESS: <CheckCircle className="h-3 w-3" />,
|
||||||
|
PARTIAL: <AlertTriangle className="h-3 w-3" />,
|
||||||
|
FAILED: <XCircle className="h-3 w-3" />,
|
||||||
|
};
|
||||||
|
|
||||||
|
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<OsmCoverageSummary | null>(null);
|
||||||
|
const [runs, setRuns] = useState<OsmSyncRun[]>([]);
|
||||||
|
const [layers, setLayers] = useState<OsmSyncLayer[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [triggering, setTriggering] = useState<string | null>(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 (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-semibold">OSM Sync Dashboard</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Đồng bộ OpenStreetMap → Goodgo: ranh giới hành chính, POI, KCN, giao thông.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={refresh} disabled={loading}>
|
||||||
|
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
Làm mới
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Top stats */}
|
||||||
|
{coverage && (
|
||||||
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-5">
|
||||||
|
<StatCard
|
||||||
|
icon={<Layers className="h-4 w-4" />}
|
||||||
|
label="Đơn vị hành chính"
|
||||||
|
value={coverage.totals.administrativeUnits.toLocaleString('vi-VN')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<MapPin className="h-4 w-4" />}
|
||||||
|
label="POI tổng"
|
||||||
|
value={coverage.totals.poiTotal.toLocaleString('vi-VN')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<MapPin className="h-4 w-4 text-green-600" />}
|
||||||
|
label="KCN"
|
||||||
|
value={coverage.totals.industrialParks.toLocaleString('vi-VN')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Train className="h-4 w-4" />}
|
||||||
|
label="Bến/Ga"
|
||||||
|
value={coverage.totals.transportStations.toLocaleString('vi-VN')}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={<Train className="h-4 w-4" />}
|
||||||
|
label="Tuyến giao thông"
|
||||||
|
value={coverage.totals.transportLines.toLocaleString('vi-VN')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Coverage table */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="border-b border-border px-4 py-2.5">
|
||||||
|
<h2 className="text-sm font-semibold">Coverage theo layer</h2>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Layer / Category</TableHead>
|
||||||
|
<TableHead className="text-right">Tổng</TableHead>
|
||||||
|
<TableHead className="text-right">Promoted</TableHead>
|
||||||
|
<TableHead className="text-right">Raw</TableHead>
|
||||||
|
<TableHead>Sync gần nhất</TableHead>
|
||||||
|
<TableHead className="text-right">Hành động</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{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 (
|
||||||
|
<TableRow key={key}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{r.layer}</div>
|
||||||
|
{r.category && (
|
||||||
|
<div className="text-xs text-muted-foreground">{r.category}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{r.total.toLocaleString('vi-VN')}
|
||||||
|
{r.withGeometry !== undefined && r.withGeometry !== r.total && (
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
({r.withGeometry} có geom)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{r.promoted?.toLocaleString('vi-VN') ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">
|
||||||
|
{r.raw?.toLocaleString('vi-VN') ?? '—'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatRelative(r.lastSyncedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
{layerDef && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={triggering === key}
|
||||||
|
onClick={() => trigger(r.layer, r.category ?? undefined)}
|
||||||
|
>
|
||||||
|
{triggering === key ? (
|
||||||
|
<RefreshCw className="mr-1 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="mr-1 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recent runs */}
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-0">
|
||||||
|
<div className="border-b border-border px-4 py-2.5">
|
||||||
|
<h2 className="text-sm font-semibold">Sync runs gần đây</h2>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Layer</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Added</TableHead>
|
||||||
|
<TableHead className="text-right">Updated</TableHead>
|
||||||
|
<TableHead className="text-right">Skipped</TableHead>
|
||||||
|
<TableHead>Bắt đầu</TableHead>
|
||||||
|
<TableHead>Thời gian</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{runs.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-8 text-center text-sm text-muted-foreground">
|
||||||
|
Chưa có sync run nào.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
runs.map((r) => (
|
||||||
|
<TableRow key={r.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{r.layer}</div>
|
||||||
|
{r.category && (
|
||||||
|
<div className="text-xs text-muted-foreground">{r.category}</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 rounded-pill px-2 py-0.5 text-xs font-medium ring-1 ring-inset ${STATUS_STYLES[r.status]}`}
|
||||||
|
>
|
||||||
|
{STATUS_ICONS[r.status]}
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{r.rowsAdded}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{r.rowsUpdated}</TableCell>
|
||||||
|
<TableCell className="text-right font-mono">{r.rowsSkipped}</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
<Clock className="mr-1 inline h-3 w-3" />
|
||||||
|
{formatRelative(r.startedAt)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
|
{formatDuration(r.startedAt, r.finishedAt)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="truncate text-xs uppercase text-muted-foreground">{label}</div>
|
||||||
|
<div className="truncate text-lg font-semibold">{value}</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Building2,
|
Building2,
|
||||||
Factory,
|
Factory,
|
||||||
|
Globe,
|
||||||
LogOut,
|
LogOut,
|
||||||
Menu,
|
Menu,
|
||||||
Sparkles,
|
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/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/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/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 },
|
{ href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
151
apps/web/components/poi/nearby-poi-sidebar.tsx
Normal file
151
apps/web/components/poi/nearby-poi-sidebar.tsx
Normal file
@@ -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<NearbyPoiResult | null>(null);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(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 (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-2 rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Đang tải tiện ích xung quanh…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.all.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-lg border border-border bg-card p-4 text-sm text-muted-foreground ${className ?? ''}`}
|
||||||
|
>
|
||||||
|
Chưa có dữ liệu tiện ích trong bán kính {formatDistance(radius)}.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-border bg-card ${className ?? ''}`}>
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">Tiện ích xung quanh</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{data.meta.totalCount} điểm · bán kính {formatDistance(data.meta.radiusMeters)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col divide-y divide-border">
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const items = data.byCategory[cat] ?? [];
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div key={cat} className="px-4 py-2.5">
|
||||||
|
<div className="mb-1.5 flex items-center gap-1.5 text-xs font-medium uppercase text-muted-foreground">
|
||||||
|
<span aria-hidden>{POI_ICONS[cat]}</span>
|
||||||
|
{POI_LABELS[cat]}
|
||||||
|
</div>
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{items.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-start gap-2 text-sm">
|
||||||
|
<MapPin className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium text-foreground">{p.name}</div>
|
||||||
|
{p.address && (
|
||||||
|
<div className="truncate text-xs text-muted-foreground">{p.address}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{formatDistance(p.distanceM)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
58
apps/web/lib/osm-sync-api.ts
Normal file
58
apps/web/lib/osm-sync-api.ts
Normal file
@@ -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<OsmSyncLayer[]>('/admin/osm/layers'),
|
||||||
|
coverage: () => apiClient.get<OsmCoverageSummary>('/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<OsmSyncRun[]>(`/admin/osm/runs${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
trigger: (body: { layer: string; category?: string; chunk?: string }) =>
|
||||||
|
apiClient.post<{ runId: string; status: string }>('/admin/osm/runs', body),
|
||||||
|
};
|
||||||
140
apps/web/lib/poi-api.ts
Normal file
140
apps/web/lib/poi-api.ts
Normal file
@@ -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<PoiCategory, string> = {
|
||||||
|
SCHOOL_PRIMARY: 'Trường tiểu học',
|
||||||
|
SCHOOL_SECONDARY: 'Trường THCS / THPT',
|
||||||
|
UNIVERSITY: 'Đại học / Cao đẳng',
|
||||||
|
HOSPITAL: 'Bệnh viện',
|
||||||
|
CLINIC: 'Phòng khám',
|
||||||
|
PHARMACY: 'Nhà thuốc',
|
||||||
|
MARKET: 'Chợ',
|
||||||
|
SUPERMARKET: 'Siêu thị',
|
||||||
|
MALL: 'TTTM',
|
||||||
|
CONVENIENCE: 'Cửa hàng tiện lợi',
|
||||||
|
BANK: 'Ngân hàng',
|
||||||
|
ATM: 'ATM',
|
||||||
|
PARK: 'Công viên',
|
||||||
|
GAS_STATION: 'Cây xăng',
|
||||||
|
POLICE: 'Công an',
|
||||||
|
POST_OFFICE: 'Bưu điện',
|
||||||
|
METRO_STATION: 'Ga Metro',
|
||||||
|
RAILWAY_STATION: 'Ga tàu',
|
||||||
|
BUS_STATION: 'Bến xe',
|
||||||
|
AIRPORT: 'Sân bay',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Single-emoji icon for chips / map markers (no extra image dep needed). */
|
||||||
|
export const POI_ICONS: Record<PoiCategory, string> = {
|
||||||
|
SCHOOL_PRIMARY: '🏫', SCHOOL_SECONDARY: '🎒', UNIVERSITY: '🎓',
|
||||||
|
HOSPITAL: '🏥', CLINIC: '⚕️', PHARMACY: '💊',
|
||||||
|
MARKET: '🛒', SUPERMARKET: '🏪', MALL: '🛍️', CONVENIENCE: '🏬',
|
||||||
|
BANK: '🏦', ATM: '🏧',
|
||||||
|
PARK: '🌳',
|
||||||
|
GAS_STATION: '⛽', POLICE: '👮', POST_OFFICE: '📮',
|
||||||
|
METRO_STATION: '🚇', RAILWAY_STATION: '🚉', BUS_STATION: '🚌', AIRPORT: '✈️',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Tailwind colour class per category — keep marker coding consistent. */
|
||||||
|
export const POI_COLORS: Record<PoiCategory, string> = {
|
||||||
|
SCHOOL_PRIMARY: '#3b82f6', SCHOOL_SECONDARY: '#2563eb', UNIVERSITY: '#1d4ed8',
|
||||||
|
HOSPITAL: '#ef4444', CLINIC: '#f87171', PHARMACY: '#fb7185',
|
||||||
|
MARKET: '#f59e0b', SUPERMARKET: '#fbbf24', MALL: '#fcd34d', CONVENIENCE: '#fde68a',
|
||||||
|
BANK: '#8b5cf6', ATM: '#a78bfa',
|
||||||
|
PARK: '#22c55e',
|
||||||
|
GAS_STATION: '#64748b', POLICE: '#0f172a', POST_OFFICE: '#be185d',
|
||||||
|
METRO_STATION: '#0ea5e9', RAILWAY_STATION: '#0284c7', BUS_STATION: '#0369a1', AIRPORT: '#075985',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface NearbyPoi {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: PoiCategory;
|
||||||
|
distanceM: number;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
address: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NearbyPoiResult {
|
||||||
|
byCategory: Partial<Record<PoiCategory, NearbyPoi[]>>;
|
||||||
|
all: NearbyPoi[];
|
||||||
|
meta: { radiusMeters: number; totalCount: number; requestedCategories: PoiCategory[] | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoiBboxFeatureCollection {
|
||||||
|
type: 'FeatureCollection';
|
||||||
|
features: {
|
||||||
|
type: 'Feature';
|
||||||
|
id: string;
|
||||||
|
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||||
|
properties: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
category: PoiCategory;
|
||||||
|
provinceCode: string | null;
|
||||||
|
districtCode: string | null;
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
meta: { count: number; truncated: boolean; categories: PoiCategory[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* API */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const poiApi = {
|
||||||
|
/**
|
||||||
|
* Fetch nearest N POI (per category) within `radius` metres of the given
|
||||||
|
* point. Drives the "tiện ích xung quanh" sidebar.
|
||||||
|
*/
|
||||||
|
nearby: (params: {
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
radius: number;
|
||||||
|
categories?: PoiCategory[];
|
||||||
|
limitPerCategory?: number;
|
||||||
|
}): Promise<NearbyPoiResult> => {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
lat: String(params.lat),
|
||||||
|
lng: String(params.lng),
|
||||||
|
radius: String(params.radius),
|
||||||
|
});
|
||||||
|
if (params.categories?.length) q.set('categories', params.categories.join(','));
|
||||||
|
if (params.limitPerCategory) q.set('limitPerCategory', String(params.limitPerCategory));
|
||||||
|
return apiClient.get<NearbyPoiResult>(`/poi/nearby?${q.toString()}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/** GeoJSON for map overlays. Used by the listing detail mini-map and KCN page. */
|
||||||
|
byBbox: (params: {
|
||||||
|
south: number;
|
||||||
|
west: number;
|
||||||
|
north: number;
|
||||||
|
east: number;
|
||||||
|
categories?: PoiCategory[];
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<PoiBboxFeatureCollection> => {
|
||||||
|
const q = new URLSearchParams({
|
||||||
|
south: String(params.south),
|
||||||
|
west: String(params.west),
|
||||||
|
north: String(params.north),
|
||||||
|
east: String(params.east),
|
||||||
|
});
|
||||||
|
if (params.categories?.length) q.set('categories', params.categories.join(','));
|
||||||
|
if (params.limit) q.set('limit', String(params.limit));
|
||||||
|
return apiClient.get<PoiBboxFeatureCollection>(`/poi/by-bbox?${q.toString()}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -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");
|
||||||
@@ -1580,9 +1580,25 @@ model VnProvince {
|
|||||||
type String // "Thành phố Trung ương" | "Tỉnh"
|
type String // "Thành phố Trung ương" | "Tỉnh"
|
||||||
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
|
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
|
||||||
phoneCode Int?
|
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[]
|
districts VnDistrict[]
|
||||||
|
|
||||||
@@index([codename])
|
@@index([codename])
|
||||||
|
@@index([geometry], type: Gist)
|
||||||
|
@@index([centroid], type: Gist)
|
||||||
|
@@index([lastSyncedAt])
|
||||||
@@map("vn_provinces")
|
@@map("vn_provinces")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1593,11 +1609,21 @@ model VnDistrict {
|
|||||||
nameEn String?
|
nameEn String?
|
||||||
type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh"
|
type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh"
|
||||||
codename String
|
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)
|
province VnProvince @relation(fields: [provinceCode], references: [code], onDelete: Restrict)
|
||||||
wards VnWard[]
|
wards VnWard[]
|
||||||
|
|
||||||
@@index([provinceCode])
|
@@index([provinceCode])
|
||||||
@@index([codename])
|
@@index([codename])
|
||||||
|
@@index([geometry], type: Gist)
|
||||||
|
@@index([centroid], type: Gist)
|
||||||
|
@@index([lastSyncedAt])
|
||||||
@@map("vn_districts")
|
@@map("vn_districts")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1608,15 +1634,166 @@ model VnWard {
|
|||||||
nameEn String?
|
nameEn String?
|
||||||
type String // "Phường" | "Xã" | "Thị trấn"
|
type String // "Phường" | "Xã" | "Thị trấn"
|
||||||
codename String
|
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)
|
district VnDistrict @relation(fields: [districtCode], references: [code], onDelete: Restrict)
|
||||||
|
|
||||||
@@index([districtCode])
|
@@index([districtCode])
|
||||||
@@index([codename])
|
@@index([codename])
|
||||||
|
@@index([geometry], type: Gist)
|
||||||
|
@@index([centroid], type: Gist)
|
||||||
|
@@index([lastSyncedAt])
|
||||||
@@map("vn_wards")
|
@@map("vn_wards")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Historical name/code changes so legacy data (e.g. Quận 2, Quận 9) and post-2025
|
/// 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.
|
/// 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 {
|
model VnAdministrativeAlias {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
oldCode String? // GSO code pre-change, when known
|
oldCode String? // GSO code pre-change, when known
|
||||||
|
|||||||
216
scripts/backfill-admin-codes.ts
Normal file
216
scripts/backfill-admin-codes.ts
Normal file
@@ -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<AdminMatch> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
519
scripts/sync-osm-admin-boundaries.ts
Normal file
519
scripts/sync-osm-admin-boundaries.ts
Normal file
@@ -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<string, BBox> = {
|
||||||
|
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<OverpassResult> {
|
||||||
|
// `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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Polygon | MultiPolygon>,
|
||||||
|
level: 4 | 6 | 8,
|
||||||
|
): ParsedAdmin | null {
|
||||||
|
const propsRaw = feat.properties as Record<string, unknown> | 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<string, string> =
|
||||||
|
tagsRaw && typeof tagsRaw === 'object'
|
||||||
|
? (tagsRaw as Record<string, string>)
|
||||||
|
: (propsRaw as Record<string, string>);
|
||||||
|
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<UpsertStats> {
|
||||||
|
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<Polygon | MultiPolygon>[]).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<void> {
|
||||||
|
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<number, UpsertStats> = {
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -215,9 +215,11 @@ function parseFeature(
|
|||||||
if (!isPointInVietnam(cLng, cLat)) return null;
|
if (!isPointInVietnam(cLng, cLat)) return null;
|
||||||
|
|
||||||
// Province resolution: prefer explicit OSM tags, then fall back to a
|
// Province resolution: prefer explicit OSM tags, then fall back to a
|
||||||
// nearest-centroid lookup against our 63-province table. The fallback
|
// nearest-centroid lookup against our 63-province table. The actual DB
|
||||||
// catches the (very common) case where Vietnamese landuse polygons have
|
// upsert step (`upsertFeature`) replaces this with a precise PostGIS
|
||||||
// no addr:* tags at all.
|
// 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 =
|
const province =
|
||||||
VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ??
|
VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ??
|
||||||
tags['addr:city'] ??
|
tags['addr:city'] ??
|
||||||
@@ -271,6 +273,33 @@ async function upsertFeature(
|
|||||||
return;
|
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 region = guessRegion(parsed.centroid.lat);
|
||||||
const slug = slugify(parsed.name, parsed.osmId.toString());
|
const slug = slugify(parsed.name, parsed.osmId.toString());
|
||||||
|
|
||||||
|
|||||||
400
scripts/sync-osm-poi.ts
Normal file
400
scripts/sync-osm-poi.ts
Normal file
@@ -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<string, BBox> = {
|
||||||
|
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<PoiCategoryKey, string> = {
|
||||||
|
// ── 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<OverpassResult> {
|
||||||
|
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<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFeature(
|
||||||
|
feat: Feature<Polygon | MultiPolygon | Point>,
|
||||||
|
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<string, unknown>;
|
||||||
|
const tagsRaw = propsRaw['tags'];
|
||||||
|
const tags: Record<string, string> =
|
||||||
|
tagsRaw && typeof tagsRaw === 'object'
|
||||||
|
? (tagsRaw as Record<string, string>)
|
||||||
|
: (propsRaw as Record<string, string>);
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const result = await fetchChunk(category, chunkName, bbox);
|
||||||
|
const fc = osmtogeojson(result, { flatProperties: false });
|
||||||
|
const features = (fc.features as Feature<Polygon | MultiPolygon | Point>[]).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<void> {
|
||||||
|
console.log(`📍 OSM POI sync: categories=${wantedCategories.join(',')} dryRun=${dryRun}`);
|
||||||
|
|
||||||
|
const chunks = chunkArg
|
||||||
|
? { [chunkArg]: CHUNKS[chunkArg]! }
|
||||||
|
: CHUNKS;
|
||||||
|
|
||||||
|
const totals: Record<string, UpsertStats> = {};
|
||||||
|
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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user