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 { HttpMetricsInterceptor, MetricsModule } from '@modules/metrics';
|
||||
import { NotificationsModule } from '@modules/notifications';
|
||||
import { OsmSyncModule } from '@modules/osm-sync/osm-sync.module';
|
||||
import { PaymentsModule } from '@modules/payments';
|
||||
import { PoiModule } from '@modules/poi/poi.module';
|
||||
import { ProjectsModule } from '@modules/projects';
|
||||
import { QueuesModule } from '@modules/queues/queues.module';
|
||||
import { ReportsModule } from '@modules/reports';
|
||||
@@ -58,7 +60,9 @@ import { AppController } from './app.controller';
|
||||
FavoritesModule,
|
||||
SearchModule,
|
||||
NotificationsModule,
|
||||
OsmSyncModule,
|
||||
PaymentsModule,
|
||||
PoiModule,
|
||||
SubscriptionsModule,
|
||||
AdminModule,
|
||||
AnalyticsModule,
|
||||
|
||||
@@ -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,
|
||||
} from './field-encryption.service';
|
||||
export { createEncryptionExtension } from './encryption-middleware';
|
||||
export { GeoLookupService, type GeoLookupResult } from './geo-lookup.service';
|
||||
export { PrismaService } from './prisma.service';
|
||||
export { RedisService } from './redis.service';
|
||||
export { RedisIoAdapter } from './redis-io.adapter';
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
// import { EVENT_BUS, RedisStreamsEventBus } from './infrastructure/event-bus';
|
||||
import { EventBusService } from './infrastructure/event-bus.service';
|
||||
import { FieldEncryptionService } from './infrastructure/field-encryption.service';
|
||||
import { GeoLookupService } from './infrastructure/geo-lookup.service';
|
||||
import { GlobalExceptionFilter } from './infrastructure/filters/global-exception.filter';
|
||||
import { DeprecationInterceptor, VersionInterceptor } from './infrastructure/interceptors';
|
||||
import { LoggerService } from './infrastructure/logger.service';
|
||||
@@ -43,6 +44,7 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
|
||||
RedisService,
|
||||
CacheService,
|
||||
EventBusService,
|
||||
GeoLookupService,
|
||||
// RFC-004 Phase 0 (GOO-172) — see import comment above.
|
||||
// { provide: EVENT_BUS, useClass: RedisStreamsEventBus },
|
||||
// OutboxService,
|
||||
@@ -78,7 +80,17 @@ import { TypesenseClientService } from './infrastructure/typesense-client.servic
|
||||
useClass: DeprecationInterceptor,
|
||||
},
|
||||
],
|
||||
exports: [PrismaService, RedisService, CacheService, LoggerService, EventBusService, FieldEncryptionService, TypesenseClientService, PrometheusModule],
|
||||
exports: [
|
||||
PrismaService,
|
||||
RedisService,
|
||||
CacheService,
|
||||
LoggerService,
|
||||
EventBusService,
|
||||
FieldEncryptionService,
|
||||
GeoLookupService,
|
||||
TypesenseClientService,
|
||||
PrometheusModule,
|
||||
],
|
||||
})
|
||||
export class SharedModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
|
||||
Reference in New Issue
Block a user