feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator

This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.

Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
  geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
  lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
  `boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
  filters to mainland VN via the existing country polygon, resolves the
  GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
  Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
  `nearestProvince()` heuristic — `lookup(lng,lat)` returns
  province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
  and falls back to the centroid heuristic only when polygons aren't
  loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
  IndustrialPark, ProjectDevelopment and Property using the new lookup.

Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
  GIST index on `location`. New `TransportLine` for metro/highway
  multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
  resolves province/district codes from the boundary polygons, upserts
  with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
    GET /poi/by-bbox    — GeoJSON for map overlays
    GET /poi/nearby     — sidebar "tiện ích xung quanh" (HMAC distance ranks)
    GET /poi/coverage   — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
  project / KCN detail pages.

Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
  (RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
  chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
    Daily 02:00  → POI category rotation (1/day, 20-day cycle)
    Mon  02:30  → admin-boundaries provinces
    Wed  02:30  → admin-boundaries districts
    Sat  02:30  → admin-boundaries wards
    1st of month 03:00 → industrial-parks (per chunk)
  All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
  trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
  per-row "Sync now", recent runs list with auto-refresh every 15s.

Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ho Ngoc Hai
2026-05-01 12:01:19 +07:00
parent 73ff469126
commit fba536406d
38 changed files with 3411 additions and 11 deletions

View File

@@ -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,

View File

@@ -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,
) {}
}

View File

@@ -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,
});
}
}

View File

@@ -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,
},
};
}
}

View File

@@ -0,0 +1,3 @@
/** Aggregate coverage view across all OSM-managed tables for the
* admin dashboard. */
export class OsmCoverageSummaryQuery {}

View File

@@ -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),
});
}
}

View File

@@ -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,
) {}
}

View File

@@ -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 });
}
}
}

View 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',
);
}
}

View 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 {}

View File

@@ -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),
);
}
}

View File

@@ -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;
}

View File

@@ -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,
}));
}
}

View File

@@ -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) {}
}

View File

@@ -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;
}
}
}

View File

@@ -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,
) {}
}

View File

@@ -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;
}
}
}

View File

@@ -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,
) {}
}

View 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 {}

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

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

View File

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

View File

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

View 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} 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 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>
);
}

View File

@@ -7,6 +7,7 @@ import {
ShieldCheck,
Building2,
Factory,
Globe,
LogOut,
Menu,
Sparkles,
@@ -38,6 +39,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
{ href: '/admin/accounts/developers' as const, label: 'Tài khoản CĐT', icon: Building2 },
{ href: '/admin/accounts/park-operators' as const, label: 'Tài khoản KCN', icon: Factory },
{ href: '/admin/industrial/osm-review' as const, label: 'Review OSM (KCN)', icon: Factory },
{ href: '/admin/osm' as const, label: 'OSM Sync Dashboard', icon: Globe },
{ href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles },
];

View 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 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>
);
}

View 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
View 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()}`);
},
};