diff --git a/.gitignore b/.gitignore index f10e5ac..cc8b6c5 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,9 @@ load-tests/results/*.json npm-debug.log* pnpm-debug.log* +# Redis dump (created when running redis locally without persistence config) +*.rdb + # personal notes / Obsidian .obsidian/ TEC/ diff --git a/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts new file mode 100644 index 0000000..8b83f28 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.command.ts @@ -0,0 +1,11 @@ +/** + * Toggle the `osmLocked` flag on a park. When locked, the OSM sync cron + * skips this row entirely — useful when admin has curated values that + * conflict with what OSM contributors keep changing. + */ +export class LockOsmParkCommand { + constructor( + public readonly parkId: string, + public readonly locked: boolean, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts new file mode 100644 index 0000000..5136b7d --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/lock-osm-park/lock-osm-park.handler.ts @@ -0,0 +1,24 @@ +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { LockOsmParkCommand } from './lock-osm-park.command'; + +@CommandHandler(LockOsmParkCommand) +export class LockOsmParkHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(cmd: LockOsmParkCommand): Promise<{ id: string; locked: boolean }> { + const park = await this.prisma.industrialPark.update({ + where: { id: cmd.parkId }, + data: { osmLocked: cmd.locked }, + select: { id: true, osmLocked: true }, + }); + this.logger.log( + `Park ${park.id} osmLocked → ${park.osmLocked}`, + this.constructor.name, + ); + return { id: park.id, locked: park.osmLocked }; + } +} diff --git a/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts new file mode 100644 index 0000000..798e1f9 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.command.ts @@ -0,0 +1,15 @@ +/** + * Promote a raw OSM-imported industrial park to the public catalogue. + * + * - Flips `dataSource` from `OSM` → `OSM_PROMOTED` + * - Sets `isPublic = true` + * - Optionally locks fields the admin has just curated so the next OSM + * sync doesn't overwrite them. + */ +export class PromoteOsmParkCommand { + constructor( + public readonly parkId: string, + /** Field names to add to `lockedFields`. Empty array = no lock. */ + public readonly lockFields: string[] = [], + ) {} +} diff --git a/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts new file mode 100644 index 0000000..7138494 --- /dev/null +++ b/apps/api/src/modules/industrial/application/commands/promote-osm-park/promote-osm-park.handler.ts @@ -0,0 +1,44 @@ +import { HttpStatus } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, ErrorCode, LoggerService, PrismaService } from '@modules/shared'; +import { PromoteOsmParkCommand } from './promote-osm-park.command'; + +@CommandHandler(PromoteOsmParkCommand) +export class PromoteOsmParkHandler implements ICommandHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(cmd: PromoteOsmParkCommand): Promise<{ id: string }> { + const park = await this.prisma.industrialPark.findUnique({ + where: { id: cmd.parkId }, + select: { id: true, dataSource: true, lockedFields: true }, + }); + if (!park) { + throw new DomainException( + ErrorCode.NOT_FOUND, + `Không tìm thấy KCN ${cmd.parkId}`, + HttpStatus.NOT_FOUND, + ); + } + if (park.dataSource === 'MANUAL') { + // Already in the public catalog as a manual seed; nothing to promote. + return { id: park.id }; + } + const newLocked = Array.from(new Set([...park.lockedFields, ...cmd.lockFields])); + await this.prisma.industrialPark.update({ + where: { id: cmd.parkId }, + data: { + dataSource: 'OSM_PROMOTED', + isPublic: true, + lockedFields: newLocked, + }, + }); + this.logger.log( + `Promoted park ${cmd.parkId} from OSM → OSM_PROMOTED (locked: ${newLocked.join(', ')})`, + this.constructor.name, + ); + return { id: park.id }; + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts new file mode 100644 index 0000000..ffea1c5 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler.ts @@ -0,0 +1,140 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import type { Feature, FeatureCollection } from 'geojson'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { GetIndustrialParksByBboxQuery } from './get-industrial-parks-by-bbox.query'; + +interface BboxRow { + id: string; + slug: string; + name: string; + status: string; + province: string; + data_source: string; + occupancy_rate: number; + total_area_ha: number; + tenant_count: number; + point: string; // GeoJSON Point as text (ST_AsGeoJSON) + polygon: string | null; // GeoJSON MultiPolygon, only when zoom >= 12 +} + +export interface IndustrialParksGeoCollection extends FeatureCollection { + /** Quick metadata so the client can show "showing N of M parks" */ + meta: { + count: number; + truncated: boolean; + zoom: number; + }; +} + +/** Zoom threshold above which the boundary polygon is included. Below this + * we send only the centroid Point — enough to cluster + render dots. */ +const BOUNDARY_ZOOM = 12; + +@QueryHandler(GetIndustrialParksByBboxQuery) +export class GetIndustrialParksByBboxHandler + implements IQueryHandler +{ + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute( + q: GetIndustrialParksByBboxQuery, + ): Promise { + try { + const { south, west, north, east } = q.bbox; + const includeBoundary = q.zoom >= BOUNDARY_ZOOM; + const limit = Math.min(Math.max(q.limit, 1), 5000); + // Filter out raw OSM imports for public consumers; admins pass + // includeOsmRaw=true to see them. + const dataSourceFilter = q.includeOsmRaw + ? `'MANUAL', 'OSM', 'OSM_PROMOTED'` + : `'MANUAL', 'OSM_PROMOTED'`; + + // Single PostGIS query: bbox filter + optional polygon column. + // && is the bbox-intersect operator and uses the GiST index. + const rows = await this.prisma.$queryRawUnsafe( + ` + SELECT + id, + slug, + name, + status::text, + province, + "dataSource"::text AS data_source, + "occupancyRate" AS occupancy_rate, + "totalAreaHa" AS total_area_ha, + "tenantCount" AS tenant_count, + ST_AsGeoJSON(location) AS point, + ${includeBoundary ? `ST_AsGeoJSON(boundary)` : `NULL::text`} AS polygon + FROM "IndustrialPark" + WHERE "isPublic" = true + AND "dataSource"::text IN (${dataSourceFilter}) + AND location && ST_MakeEnvelope($1, $2, $3, $4, 4326) + ORDER BY "totalAreaHa" DESC NULLS LAST + LIMIT ${limit + 1} + `, + west, + south, + east, + north, + ); + + const truncated = rows.length > limit; + const trimmed = truncated ? rows.slice(0, limit) : rows; + + const features: Feature[] = trimmed.flatMap((r) => { + const properties = { + id: r.id, + slug: r.slug, + name: r.name, + status: r.status, + province: r.province, + dataSource: r.data_source, + occupancyRate: Number(r.occupancy_rate), + totalAreaHa: Number(r.total_area_ha), + tenantCount: Number(r.tenant_count), + }; + const out: Feature[] = []; + if (r.point) { + out.push({ + type: 'Feature', + id: `${r.id}:point`, + geometry: JSON.parse(r.point), + properties: { ...properties, _kind: 'point' }, + }); + } + if (r.polygon) { + out.push({ + type: 'Feature', + id: `${r.id}:polygon`, + geometry: JSON.parse(r.polygon), + properties: { ...properties, _kind: 'polygon' }, + }); + } + return out; + }); + + return { + type: 'FeatureCollection', + features, + meta: { + count: trimmed.length, + truncated, + zoom: q.zoom, + }, + }; + } catch (err) { + this.logger.error( + `Failed to query parks by bbox: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể tải KCN theo khu vực. Vui lòng thử lại sau.', + ); + } + } +} diff --git a/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts new file mode 100644 index 0000000..d965987 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query.ts @@ -0,0 +1,20 @@ +/** + * Spatial bbox query for the public KCN map. Returns a GeoJSON + * FeatureCollection of industrial parks intersecting the given viewport. + * + * - At low zoom we return Point centroids only (cluster on the client). + * - At high zoom (>= 12) we also include the MultiPolygon `boundary` + * so Mapbox can render the park outline. + * + * Visibility is filtered to MANUAL + OSM_PROMOTED rows by default + * (`includeOsmRaw=false`); admin tooling can pass `true` to see the raw + * OSM-imported parks awaiting review. + */ +export class GetIndustrialParksByBboxQuery { + constructor( + public readonly bbox: { south: number; west: number; north: number; east: number }, + public readonly zoom: number, + public readonly includeOsmRaw: boolean = false, + public readonly limit: number = 1000, + ) {} +} diff --git a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts new file mode 100644 index 0000000..d5445b3 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.handler.ts @@ -0,0 +1,132 @@ +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { LoggerService, PrismaService } from '@modules/shared'; +import { ListOsmPendingQuery } from './list-osm-pending.query'; + +export interface OsmPendingItem { + id: string; + slug: string; + name: string; + nameEn: string | null; + province: string; + district: string; + region: string; + status: string; + osmId: string; + osmType: string | null; + osmTags: unknown; + totalAreaHa: number; + developer: string; + operator: string | null; + osmLocked: boolean; + lastSyncedAt: Date | null; + latitude: number | null; + longitude: number | null; +} + +export interface ListOsmPendingResult { + data: OsmPendingItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +@QueryHandler(ListOsmPendingQuery) +export class ListOsmPendingHandler implements IQueryHandler { + constructor( + private readonly prisma: PrismaService, + private readonly logger: LoggerService, + ) {} + + async execute(q: ListOsmPendingQuery): Promise { + const limit = Math.min(Math.max(q.limit, 1), 200); + const offset = (Math.max(q.page, 1) - 1) * limit; + + const conditions: string[] = [`"dataSource"::text = 'OSM'`]; + const values: unknown[] = []; + let p = 1; + + if (q.province) { + conditions.push(`province = $${p++}`); + values.push(q.province); + } + if (q.query) { + conditions.push( + `(name ILIKE $${p} OR "nameEn" ILIKE $${p} OR developer ILIKE $${p})`, + ); + values.push(`%${q.query}%`); + p += 1; + } + const where = conditions.join(' AND '); + + const [{ count }] = await this.prisma.$queryRawUnsafe<[{ count: bigint }]>( + `SELECT COUNT(*)::bigint AS count FROM "IndustrialPark" WHERE ${where}`, + ...values, + ); + const total = Number(count); + + const rows = await this.prisma.$queryRawUnsafe< + Array<{ + id: string; + slug: string; + name: string; + nameEn: string | null; + province: string; + district: string; + region: string; + status: string; + osmId: bigint; + osmType: string | null; + osmTags: unknown; + totalAreaHa: number; + developer: string; + operator: string | null; + osmLocked: boolean; + lastSyncedAt: Date | null; + lat: number | null; + lng: number | null; + }> + >( + `SELECT + id, slug, name, "nameEn", province, district, + region::text AS region, status::text AS status, + "osmId", "osmType"::text AS "osmType", "osmTags", + "totalAreaHa", developer, operator, "osmLocked", "lastSyncedAt", + ST_Y(location::geometry) AS lat, ST_X(location::geometry) AS lng + FROM "IndustrialPark" + WHERE ${where} + ORDER BY "lastSyncedAt" DESC NULLS LAST, "totalAreaHa" DESC NULLS LAST + LIMIT $${p++} OFFSET $${p}`, + ...values, + limit, + offset, + ); + + return { + data: rows.map((r) => ({ + id: r.id, + slug: r.slug, + name: r.name, + nameEn: r.nameEn, + province: r.province, + district: r.district, + region: r.region, + status: r.status, + osmId: r.osmId.toString(), + osmType: r.osmType, + osmTags: r.osmTags, + totalAreaHa: Number(r.totalAreaHa), + developer: r.developer, + operator: r.operator, + osmLocked: r.osmLocked, + lastSyncedAt: r.lastSyncedAt, + latitude: r.lat, + longitude: r.lng, + })), + total, + page: q.page, + limit, + totalPages: Math.ceil(total / limit), + }; + } +} diff --git a/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts new file mode 100644 index 0000000..15226a8 --- /dev/null +++ b/apps/api/src/modules/industrial/application/queries/list-osm-pending/list-osm-pending.query.ts @@ -0,0 +1,12 @@ +/** + * Admin OSM review queue — list raw OSM-imported parks that haven't yet + * been promoted to the public catalogue. + */ +export class ListOsmPendingQuery { + constructor( + public readonly page: number = 1, + public readonly limit: number = 50, + public readonly query?: string, + public readonly province?: string, + ) {} +} diff --git a/apps/api/src/modules/industrial/industrial.module.ts b/apps/api/src/modules/industrial/industrial.module.ts index b1e739b..9357f73 100644 --- a/apps/api/src/modules/industrial/industrial.module.ts +++ b/apps/api/src/modules/industrial/industrial.module.ts @@ -5,6 +5,8 @@ import { CreateIndustrialListingHandler } from './application/commands/create-in import { CreateIndustrialParkHandler } from './application/commands/create-industrial-park/create-industrial-park.handler'; import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler'; import { DeleteIndustrialParkHandler } from './application/commands/delete-industrial-park/delete-industrial-park.handler'; +import { LockOsmParkHandler } from './application/commands/lock-osm-park/lock-osm-park.handler'; +import { PromoteOsmParkHandler } from './application/commands/promote-osm-park/promote-osm-park.handler'; import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler'; import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler'; import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler'; @@ -12,12 +14,15 @@ import { CompareIndustrialParksHandler } from './application/queries/compare-ind import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler'; import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler'; import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler'; +import { GetIndustrialParksByBboxHandler } from './application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.handler'; import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler'; import { IndustrialParkStatsHandler } from './application/queries/industrial-park-stats/industrial-park-stats.handler'; import { ListIndustrialListingsHandler } from './application/queries/list-industrial-listings/list-industrial-listings.handler'; import { ListIndustrialParksHandler } from './application/queries/list-industrial-parks/list-industrial-parks.handler'; +import { ListOsmPendingHandler } from './application/queries/list-osm-pending/list-osm-pending.handler'; import { INDUSTRIAL_LISTING_REPOSITORY } from './domain/repositories/industrial-listing.repository'; import { INDUSTRIAL_PARK_REPOSITORY } from './domain/repositories/industrial-park.repository'; +import { OsmSyncCronService } from './infrastructure/cron/osm-sync-cron.service'; import { PrismaIndustrialListingRepository } from './infrastructure/repositories/prisma-industrial-listing.repository'; import { PrismaIndustrialParkRepository } from './infrastructure/repositories/prisma-industrial-park.repository'; import { TypesenseIndustrialService } from './infrastructure/services/typesense-industrial.service'; @@ -31,18 +36,22 @@ const CommandHandlers = [ CreateIndustrialListingHandler, UpdateIndustrialListingHandler, DeleteIndustrialListingHandler, + PromoteOsmParkHandler, + LockOsmParkHandler, ]; const QueryHandlers = [ AnalyzeIndustrialLocationHandler, EstimateIndustrialRentHandler, GetIndustrialParkHandler, + GetIndustrialParksByBboxHandler, ListIndustrialParksHandler, CompareIndustrialParksHandler, IndustrialParkStatsHandler, IndustrialMarketHandler, GetIndustrialListingHandler, ListIndustrialListingsHandler, + ListOsmPendingHandler, ]; @Module({ @@ -52,6 +61,7 @@ const QueryHandlers = [ { provide: INDUSTRIAL_PARK_REPOSITORY, useClass: PrismaIndustrialParkRepository }, { provide: INDUSTRIAL_LISTING_REPOSITORY, useClass: PrismaIndustrialListingRepository }, TypesenseIndustrialService, + OsmSyncCronService, ...CommandHandlers, ...QueryHandlers, ], diff --git a/apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts b/apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts new file mode 100644 index 0000000..a3ca341 --- /dev/null +++ b/apps/api/src/modules/industrial/infrastructure/cron/osm-sync-cron.service.ts @@ -0,0 +1,96 @@ +import { spawn } from 'node:child_process'; +import * as path from 'node:path'; +import { Injectable } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { LoggerService } from '@modules/shared'; + +/** + * Monthly OSM industrial-park reconciliation. Schedules the same script + * we run manually for the bulk import (`scripts/sync-osm-industrial-parks.ts`) + * — this keeps the import logic in one place. The cron is a thin + * orchestrator that: + * + * • Spawns the sync script (one chunk at a time to avoid Overpass 504s) + * • Logs stdout/stderr line-by-line into the application logger + * • Skips entirely if `OSM_SYNC_ENABLED !== 'true'` so dev environments + * don't accidentally call Overpass + * + * Because the script uses upsert keyed on `osmId` and respects the + * `osmLocked` / `lockedFields` columns from PR 1, replays are safe — new + * OSM entities are added, removed entities stay in the DB (admin can + * delete them via the review UI), and admin-edited fields are preserved. + */ +@Injectable() +export class OsmSyncCronService { + constructor(private readonly logger: LoggerService) {} + + /** Run on the 1st of each month at 02:00 ICT. */ + @Cron('0 2 1 * *', { timeZone: 'Asia/Ho_Chi_Minh' }) + async monthlySync(): Promise { + if (process.env['OSM_SYNC_ENABLED'] !== 'true') { + this.logger.log( + 'OSM_SYNC_ENABLED != true — skipping monthly sync.', + 'OsmSyncCronService', + ); + return; + } + this.logger.log('Starting monthly OSM sync…', 'OsmSyncCronService'); + + const chunks = ['north', 'northCentral', 'southCentral', 'south']; + for (const chunk of chunks) { + try { + await this.runChunk(chunk); + } catch (err) { + this.logger.error( + `OSM sync chunk "${chunk}" failed: ${err instanceof Error ? err.message : err}`, + err instanceof Error ? err.stack : undefined, + 'OsmSyncCronService', + ); + // Continue with the next chunk — partial success is better than + // failing the whole pass. + } + } + this.logger.log('Monthly OSM sync complete.', 'OsmSyncCronService'); + } + + private runChunk(chunk: string): Promise { + return new Promise((resolve, reject) => { + const scriptPath = path.resolve( + __dirname, + '../../../../../../..', + 'scripts/sync-osm-industrial-parks.ts', + ); + const child = spawn( + 'pnpm', + ['tsx', scriptPath, `--chunk=${chunk}`], + { + cwd: path.resolve(__dirname, '../../../../../../..'), + env: { + ...process.env, + NODE_OPTIONS: '-r dotenv/config', + DOTENV_CONFIG_PATH: '.env', + }, + }, + ); + child.stdout?.on('data', (b) => { + for (const line of b.toString().split('\n')) { + if (line.trim()) { + this.logger.log(`[${chunk}] ${line.trim()}`, 'OsmSyncCronService'); + } + } + }); + child.stderr?.on('data', (b) => { + for (const line of b.toString().split('\n')) { + if (line.trim()) { + this.logger.warn(`[${chunk}] ${line.trim()}`, 'OsmSyncCronService'); + } + } + }); + child.on('error', reject); + child.on('exit', (code) => { + if (code === 0) resolve(); + else reject(new Error(`sync-osm-industrial-parks exited ${code}`)); + }); + }); + } +} diff --git a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts index 95ec387..86feee5 100644 --- a/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts +++ b/apps/api/src/modules/industrial/infrastructure/repositories/prisma-industrial-park.repository.ts @@ -160,6 +160,11 @@ export class PrismaIndustrialParkRepository implements IIndustrialParkRepository if (params.ownerId) { conditions.push(`"ownerId" = $${paramIndex++}`); values.push(params.ownerId); + } else { + // Public list: hide raw OSM imports until an admin reviews + promotes + // them. MANUAL rows + OSM_PROMOTED rows stay visible. + conditions.push(`"isPublic" = true`); + conditions.push(`"dataSource"::text IN ('MANUAL', 'OSM_PROMOTED')`); } if (params.query) { conditions.push(`(name ILIKE $${paramIndex} OR "nameEn" ILIKE $${paramIndex} OR developer ILIKE $${paramIndex} OR province ILIKE $${paramIndex})`); diff --git a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts index 4fb873f..0e4d167 100644 --- a/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts +++ b/apps/api/src/modules/industrial/presentation/controllers/industrial-parks.controller.ts @@ -6,18 +6,23 @@ import { CurrentUser, JwtAuthGuard, Roles, RolesGuard, type JwtPayload } from ' import { NotFoundException } from '@modules/shared'; import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command'; import { DeleteIndustrialParkCommand } from '../../application/commands/delete-industrial-park/delete-industrial-park.command'; +import { LockOsmParkCommand } from '../../application/commands/lock-osm-park/lock-osm-park.command'; +import { PromoteOsmParkCommand } from '../../application/commands/promote-osm-park/promote-osm-park.command'; import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command'; import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query'; import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query'; import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query'; import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query'; +import { GetIndustrialParksByBboxQuery } from '../../application/queries/get-industrial-parks-by-bbox/get-industrial-parks-by-bbox.query'; import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query'; import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query'; import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query'; +import { ListOsmPendingQuery } from '../../application/queries/list-osm-pending/list-osm-pending.query'; import { AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto'; import { CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto'; import { CreateIndustrialParkDto } from '../dto/create-industrial-park.dto'; import { EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto'; +import { IndustrialParksBboxDto } from '../dto/parks-bbox.dto'; import { SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto'; import { UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto'; @@ -50,6 +55,24 @@ export class IndustrialParksController { ); } + @ApiOperation({ + summary: 'KCN trong viewport bản đồ', + description: + 'Trả về GeoJSON FeatureCollection của KCN nằm trong bbox. Zoom < 12 chỉ trả centroid Point, zoom >= 12 kèm MultiPolygon outline.', + }) + @ApiResponse({ status: 200, description: 'GeoJSON FeatureCollection + meta' }) + @Get('parks/by-bbox') + async parksByBbox(@Query() dto: IndustrialParksBboxDto) { + return this.queryBus.execute( + new GetIndustrialParksByBboxQuery( + { south: dto.south, west: dto.west, north: dto.north, east: dto.east }, + dto.zoom, + dto.includeOsmRaw ?? false, + dto.limit ?? 1000, + ), + ); + } + // ── Park Operator endpoints ─────────────────────────────────────── @ApiOperation({ @@ -261,4 +284,68 @@ export class IndustrialParksController { ); return { success: true }; } + + // ── OSM review & promote (admin only) ──────────────────────────────── + + @ApiOperation({ + summary: 'Hàng đợi review OSM (admin)', + description: + 'Liệt kê các KCN có dataSource=OSM (chưa được duyệt). Admin có thể promote → public hoặc lock để bảo vệ.', + }) + @ApiResponse({ status: 200, description: 'Danh sách KCN đang chờ review' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Get('parks/osm/pending') + async listOsmPending( + @Query('page') page?: string, + @Query('limit') limit?: string, + @Query('q') q?: string, + @Query('province') province?: string, + ) { + return this.queryBus.execute( + new ListOsmPendingQuery( + page ? parseInt(page, 10) : 1, + limit ? parseInt(limit, 10) : 50, + q, + province, + ), + ); + } + + @ApiOperation({ + summary: 'Promote KCN từ OSM (admin)', + description: + 'Chuyển dataSource OSM → OSM_PROMOTED và set isPublic=true. Có thể lock các field admin vừa edit.', + }) + @ApiResponse({ status: 200, description: 'Đã promote' }) + @ApiResponse({ status: 404, description: 'Không tìm thấy KCN' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post('parks/:id/osm/promote') + async promoteOsm( + @Param('id') id: string, + @Body() body: { lockFields?: string[] }, + ) { + return this.commandBus.execute( + new PromoteOsmParkCommand(id, body.lockFields ?? []), + ); + } + + @ApiOperation({ + summary: 'Lock/unlock OSM sync cho KCN (admin)', + description: 'Khi locked=true, sync cron sẽ bỏ qua row này hoàn toàn.', + }) + @ApiResponse({ status: 200, description: 'Đã cập nhật trạng thái lock' }) + @ApiBearerAuth('JWT') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN) + @Post('parks/:id/osm/lock') + async lockOsm( + @Param('id') id: string, + @Body() body: { locked: boolean }, + ) { + return this.commandBus.execute(new LockOsmParkCommand(id, body.locked)); + } } diff --git a/apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts b/apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts new file mode 100644 index 0000000..8ebdd39 --- /dev/null +++ b/apps/api/src/modules/industrial/presentation/dto/parks-bbox.dto.ts @@ -0,0 +1,63 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform, Type } from 'class-transformer'; +import { IsBoolean, IsInt, IsNumber, Max, Min } from 'class-validator'; + +/** + * Query params for `GET /industrial/parks/by-bbox`. + * + * The bbox covers the user's current Mapbox viewport. `zoom` controls + * whether the response includes polygon boundaries (zoom >= 12) or just + * Point centroids. + */ +export class IndustrialParksBboxDto { + @ApiProperty({ example: 8.0, description: 'Southern (min) latitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + south!: number; + + @ApiProperty({ example: 102.0, description: 'Western (min) longitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + west!: number; + + @ApiProperty({ example: 23.5, description: 'Northern (max) latitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-90) + @Max(90) + north!: number; + + @ApiProperty({ example: 110.0, description: 'Eastern (max) longitude of the viewport' }) + @Type(() => Number) + @IsNumber() + @Min(-180) + @Max(180) + east!: number; + + @ApiProperty({ example: 8, description: 'Mapbox zoom level (0-22)' }) + @Type(() => Number) + @IsInt() + @Min(0) + @Max(22) + zoom!: number; + + @ApiProperty({ + required: false, + default: false, + description: 'Include raw OSM imports (admin only). Default: false.', + }) + @Transform(({ value }) => value === 'true' || value === true) + @IsBoolean() + includeOsmRaw?: boolean = false; + + @ApiProperty({ required: false, default: 1000 }) + @Type(() => Number) + @IsInt() + @Min(1) + @Max(5000) + limit?: number = 1000; +} diff --git a/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx b/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx new file mode 100644 index 0000000..37b6cde --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/industrial/osm-review/page.tsx @@ -0,0 +1,500 @@ +'use client'; + +import { + CheckCircle, + Lock, + LockOpen, + RefreshCw, + ChevronLeft, + ChevronRight, + ExternalLink, + X, + Search, + AlertTriangle, +} from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Link } from '@/i18n/navigation'; +import { + industrialApi, + type OsmPendingItem, + type OsmPendingResult, +} from '@/lib/khu-cong-nghiep-api'; + +/** + * Admin OSM review queue. Lists parks with `dataSource = 'OSM'` (raw imports + * from the monthly Overpass sync). Admins decide what to do with each row: + * + * - Promote → flips `dataSource` to `OSM_PROMOTED` and `isPublic = true`, + * so the row shows up in the public catalog. Optionally lock specific + * fields so the next sync run won't overwrite them. + * - Lock / Unlock → toggles `osmLocked`. When locked, the row is skipped + * entirely by the sync cron. + * + * Fields that admins commonly want to lock after edits: `name`, `developer`, + * `description`, `targetIndustries`. We surface these as quick-pick checkboxes + * in the promote dialog, plus a free-text fallback for anything else. + */ + +const PAGE_SIZE = 50; + +const QUICK_LOCK_FIELDS: { key: string; label: string }[] = [ + { key: 'name', label: 'Tên KCN' }, + { key: 'developer', label: 'Chủ đầu tư' }, + { key: 'description', label: 'Mô tả' }, + { key: 'targetIndustries', label: 'Ngành mục tiêu' }, + { key: 'totalAreaHa', label: 'Diện tích' }, + { key: 'status', label: 'Trạng thái' }, +]; + +function formatTags(tags: Record | null): string { + if (!tags) return '—'; + // Surface the most useful keys first, then anything else, capped to keep + // the cell readable. Tag values are user-generated on OSM so we trim hard. + const priorityKeys = ['name', 'name:vi', 'name:en', 'operator', 'website']; + const ordered = [ + ...priorityKeys.filter((k) => k in tags), + ...Object.keys(tags).filter((k) => !priorityKeys.includes(k)), + ]; + return ordered + .slice(0, 4) + .map((k) => `${k}=${String(tags[k]).slice(0, 30)}`) + .join(', '); +} + +export default function AdminOsmReviewPage() { + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [actionError, setActionError] = useState(null); + const [page, setPage] = useState(1); + + // Filters + const [searchInput, setSearchInput] = useState(''); + const [search, setSearch] = useState(''); + const [provinceFilter, setProvinceFilter] = useState(''); + + // Promote dialog state + const [promoteTarget, setPromoteTarget] = useState(null); + const [lockFields, setLockFields] = useState>(new Set()); + const [extraField, setExtraField] = useState(''); + const [actionLoading, setActionLoading] = useState(false); + + const fetchQueue = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await industrialApi.listOsmPending({ + page, + limit: PAGE_SIZE, + q: search || undefined, + province: provinceFilter || undefined, + }); + setResult(data); + } catch (e) { + setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi OSM'); + } finally { + setLoading(false); + } + }, [page, search, provinceFilter]); + + useEffect(() => { + fetchQueue(); + }, [fetchQueue]); + + const submitSearch = (e: React.FormEvent) => { + e.preventDefault(); + setPage(1); + setSearch(searchInput.trim()); + }; + + const handleToggleLock = async (item: OsmPendingItem) => { + setActionError(null); + try { + await industrialApi.lockOsm(item.id, !item.osmLocked); + fetchQueue(); + } catch (e) { + setActionError(e instanceof Error ? e.message : 'Không thể cập nhật trạng thái lock'); + } + }; + + const openPromoteDialog = (item: OsmPendingItem) => { + setPromoteTarget(item); + // Default: lock the name (so the next OSM sync doesn't rename it back to + // whatever Overpass has). Admins can uncheck if they want OSM to win. + setLockFields(new Set(['name'])); + setExtraField(''); + setActionError(null); + }; + + const closePromoteDialog = () => { + setPromoteTarget(null); + setLockFields(new Set()); + setExtraField(''); + }; + + const handlePromote = async () => { + if (!promoteTarget) return; + setActionLoading(true); + setActionError(null); + try { + const fields = Array.from(lockFields); + const extras = extraField + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + await industrialApi.promoteOsm(promoteTarget.id, [...fields, ...extras]); + closePromoteDialog(); + fetchQueue(); + } catch (e) { + setActionError(e instanceof Error ? e.message : 'Promote thất bại. Vui lòng thử lại.'); + } finally { + setActionLoading(false); + } + }; + + const toggleLockField = (key: string) => { + setLockFields((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + + return ( +
+ {actionError && ( +
+ {actionError} + +
+ )} + + {/* Header */} +
+
+

Review KCN từ OpenStreetMap

+

+ Xét duyệt các KCN nhập từ OSM (chưa public). Promote → public catalog hoặc lock để giữ nguyên dữ liệu. +

+
+ +
+ + {/* Filters */} + + +
+
+ + setSearchInput(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + { + setPage(1); + setProvinceFilter(e.target.value); + }} + className="h-8 text-sm" + /> +
+ + {(search || provinceFilter) && ( + + )} +
+
+
+ + {/* Table */} + + + {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+ +
+ ) : !result || result.data.length === 0 ? ( +
+ +

Không có KCN nào trong hàng đợi

+
+ ) : ( +
+ + + + + Tên KCN + + + Tỉnh + + + Diện tích (ha) + + + OSM + + + Tags + + + Trạng thái + + + Hành động + + + + + {result.data.map((item) => ( + + +
+ {item.name} +
+ {item.nameEn && ( +
+ {item.nameEn} +
+ )} +
+ + {item.province} + + + {item.totalAreaHa + ? new Intl.NumberFormat('vi-VN', { + maximumFractionDigits: 1, + }).format(item.totalAreaHa) + : '—'} + + +
+ {item.osmType?.toLowerCase() ?? '—'}/{item.osmId} + {item.latitude != null && item.longitude != null && ( + + + + )} +
+
+ + {formatTags(item.osmTags)} + + + {item.osmLocked ? ( + + + Locked + + ) : ( + + Pending + + )} + + +
+ {item.latitude != null && item.longitude != null && ( + + + + )} + + +
+
+
+ ))} +
+
+ + {result.totalPages > 1 && ( +
+ + Trang {result.page}/{result.totalPages} · {result.total} KCN + +
+ + +
+
+ )} +
+ )} +
+
+ + {/* Promote dialog */} + { + if (!open) closePromoteDialog(); + }} + > + + + Promote KCN từ OSM + + + + + KCN {promoteTarget?.name} sẽ được chuyển sang trạng thái public + (OSM_PROMOTED). Chọn các trường muốn khóa để bảo vệ chúng khỏi OSM sync sau này. + + + + + +
+
+

Khóa các trường:

+
+ {QUICK_LOCK_FIELDS.map(({ key, label }) => ( + + ))} +
+
+ +
+

+ Trường tùy chỉnh (cách nhau bởi dấu phẩy) +

+ setExtraField(e.target.value)} + className="h-8 text-sm" + /> +
+
+ + + + + +
+
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx index ad62c7a..811ae71 100644 --- a/apps/web/app/[locale]/(admin)/layout.tsx +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -37,6 +37,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/audit-log' as const, label: 'Nhật ký kiểm toán', icon: ScrollText }, { 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/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles }, ]; diff --git a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx index 6508d3b..bf3e519 100644 --- a/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx +++ b/apps/web/app/[locale]/(public)/khu-cong-nghiep/page.tsx @@ -2,9 +2,9 @@ import { Factory, Map as MapIcon, List, Columns } from 'lucide-react'; import * as React from 'react'; +import { OsmParkBboxMap } from '@/components/khu-cong-nghiep/osm-park-bbox-map'; import { ParkCard } from '@/components/khu-cong-nghiep/park-card'; import { ParkFilterBar } from '@/components/khu-cong-nghiep/park-filter-bar'; -import { ParkMap } from '@/components/khu-cong-nghiep/park-map'; import { Button } from '@/components/ui/button'; import { useIndustrialParksSearch } from '@/lib/hooks/use-khu-cong-nghiep'; import type { SearchIndustrialParksParams } from '@/lib/khu-cong-nghiep-api'; @@ -111,12 +111,12 @@ export default function KhuCongNghiepPage() { {data.total} khu công nghiệp được tìm thấy

- {/* Map-only view */} + {/* Map-only view — bbox-driven, loads ALL parks in viewport */} {viewMode === 'map' && ( - + )} - {/* Split view: list left, sticky map right (lg+ only) */} + {/* Split view: list left, sticky bbox map right (lg+ only) */} {viewMode === 'split' && (
@@ -127,10 +127,7 @@ export default function KhuCongNghiepPage() {
- +
)} diff --git a/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx b/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx new file mode 100644 index 0000000..6e7cbcb --- /dev/null +++ b/apps/web/components/khu-cong-nghiep/osm-park-bbox-map.tsx @@ -0,0 +1,288 @@ +'use client'; + +/* eslint-disable import-x/no-named-as-default-member */ +import mapboxgl from 'mapbox-gl'; +import * as React from 'react'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import { useMapboxStyle } from '@/lib/mapbox-style'; + +const VN_CENTER: [number, number] = [106.0, 16.0]; +const DEFAULT_ZOOM = 5; + +const SOURCE_ID = 'osm-parks'; +const CLUSTER_LAYER_ID = 'osm-parks-clusters'; +const CLUSTER_COUNT_LAYER_ID = 'osm-parks-cluster-count'; +const POINT_LAYER_ID = 'osm-parks-points'; +const BOUNDARY_FILL_LAYER_ID = 'osm-parks-boundaries-fill'; +const BOUNDARY_LINE_LAYER_ID = 'osm-parks-boundaries-line'; + +interface OsmParkBboxMapProps { + className?: string; + /** Override the bbox API path. Default = `${NEXT_PUBLIC_API_URL}/industrial/parks/by-bbox`. */ + apiPath?: string; + /** Show raw OSM-imported parks (admin tools). Default false. */ + includeOsmRaw?: boolean; +} + +/** + * Viewport-driven KCN map. Pulls parks from the bbox endpoint as the user + * pans/zooms — clusters at low zoom (<12), shows polygon outlines at + * high zoom. Designed for the public catalog where we have ~2000 OSM + * imports + 50 curated rows; loading the entire dataset eagerly would + * be wasteful. + */ +export function OsmParkBboxMap({ + className, + apiPath, + includeOsmRaw = false, +}: OsmParkBboxMapProps) { + const containerRef = React.useRef(null); + const mapRef = React.useRef(null); + const fetchAbortRef = React.useRef(null); + const mapStyle = useMapboxStyle(); + + const apiBase = React.useMemo(() => { + if (apiPath) return apiPath; + const apiUrl = process.env['NEXT_PUBLIC_API_URL'] ?? 'http://localhost:3201/api/v1'; + return `${apiUrl}/industrial/parks/by-bbox`; + }, [apiPath]); + + // Capture the current includeOsmRaw value via a ref so the moveend + // handler always sees the latest without re-binding the listener. + const includeOsmRawRef = React.useRef(includeOsmRaw); + React.useEffect(() => { + includeOsmRawRef.current = includeOsmRaw; + }, [includeOsmRaw]); + + React.useEffect(() => { + if (!containerRef.current) return; + const token = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + if (!token) return; + mapboxgl.accessToken = token; + + const map = new mapboxgl.Map({ + container: containerRef.current, + style: mapStyle, + center: VN_CENTER, + zoom: DEFAULT_ZOOM, + attributionControl: false, + }); + map.addControl(new mapboxgl.NavigationControl(), 'top-right'); + map.addControl( + new mapboxgl.AttributionControl({ compact: true, customAttribution: 'Data © OSM' }), + 'bottom-right', + ); + mapRef.current = map; + + const fetchParks = async () => { + try { + // Cancel any in-flight request — only the latest viewport matters. + fetchAbortRef.current?.abort(); + const controller = new AbortController(); + fetchAbortRef.current = controller; + const bounds = map.getBounds(); + if (!bounds) return; + const sw = bounds.getSouthWest(); + const ne = bounds.getNorthEast(); + const zoom = Math.round(map.getZoom()); + const params = new URLSearchParams({ + south: sw.lat.toString(), + west: sw.lng.toString(), + north: ne.lat.toString(), + east: ne.lng.toString(), + zoom: zoom.toString(), + ...(includeOsmRawRef.current ? { includeOsmRaw: 'true' } : {}), + }); + const res = await fetch(`${apiBase}?${params}`, { + credentials: 'include', + signal: controller.signal, + }); + if (!res.ok) return; + const fc = (await res.json()) as GeoJSON.FeatureCollection; + const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource | undefined; + if (src) src.setData(fc); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return; + console.warn('[osm-park-bbox-map] fetch failed:', err); + } + }; + + map.on('load', () => { + // Empty source — populated by the first fetchParks() call below. + map.addSource(SOURCE_ID, { + type: 'geojson', + data: { type: 'FeatureCollection', features: [] }, + cluster: true, + clusterRadius: 50, + clusterMaxZoom: 11, + clusterProperties: { + // No extra metrics yet — total count is built-in. + }, + }); + + // Cluster bubbles. Mapbox color parser only accepts literal colors, + // so we use hex constants matching our design-system primary token. + map.addLayer({ + id: CLUSTER_LAYER_ID, + type: 'circle', + source: SOURCE_ID, + filter: ['has', 'point_count'], + paint: { + 'circle-color': [ + 'step', + ['get', 'point_count'], + '#22c55e', // primary + 10, + '#f59e0b', + 50, + '#ef4444', + ], + 'circle-radius': [ + 'step', + ['get', 'point_count'], + 16, + 10, + 22, + 50, + 30, + ], + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff', + 'circle-opacity': 0.9, + }, + }); + + map.addLayer({ + id: CLUSTER_COUNT_LAYER_ID, + type: 'symbol', + source: SOURCE_ID, + filter: ['has', 'point_count'], + layout: { + 'text-field': ['get', 'point_count_abbreviated'], + 'text-font': ['DIN Offc Pro Medium', 'Arial Unicode MS Bold'], + 'text-size': 12, + }, + paint: { 'text-color': '#ffffff' }, + }); + + // Individual park markers (centroid Points) when not clustered. + map.addLayer({ + id: POINT_LAYER_ID, + type: 'circle', + source: SOURCE_ID, + filter: [ + 'all', + ['!', ['has', 'point_count']], + ['==', ['get', '_kind'], 'point'], + ], + paint: { + 'circle-color': '#22c55e', + 'circle-radius': 6, + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 1.5, + }, + }); + + // Polygon outlines — only present when zoom >= 12 (server omits them + // at lower zoom). Fill layer for hit-test, line layer for stroke. + map.addLayer({ + id: BOUNDARY_FILL_LAYER_ID, + type: 'fill', + source: SOURCE_ID, + filter: ['==', ['get', '_kind'], 'polygon'], + paint: { + 'fill-color': '#22c55e', + 'fill-opacity': 0.18, + }, + }); + map.addLayer({ + id: BOUNDARY_LINE_LAYER_ID, + type: 'line', + source: SOURCE_ID, + filter: ['==', ['get', '_kind'], 'polygon'], + paint: { + 'line-color': '#22c55e', + 'line-width': 2, + 'line-opacity': 0.6, + }, + }); + + // Click handler on point/polygon → navigate to detail. + const onClick = (e: mapboxgl.MapLayerMouseEvent) => { + const f = e.features?.[0]; + if (!f) return; + const slug = (f.properties as Record | null)?.['slug']; + if (typeof slug === 'string' && slug.length > 0) { + window.location.href = `/vi/khu-cong-nghiep/${slug}`; + } + }; + map.on('click', POINT_LAYER_ID, onClick); + map.on('click', BOUNDARY_FILL_LAYER_ID, onClick); + // Cursor feedback + for (const layerId of [POINT_LAYER_ID, BOUNDARY_FILL_LAYER_ID, CLUSTER_LAYER_ID]) { + map.on('mouseenter', layerId, () => { + map.getCanvas().style.cursor = 'pointer'; + }); + map.on('mouseleave', layerId, () => { + map.getCanvas().style.cursor = ''; + }); + } + // Cluster click — zoom in + map.on('click', CLUSTER_LAYER_ID, (e) => { + const features = map.queryRenderedFeatures(e.point, { layers: [CLUSTER_LAYER_ID] }); + const clusterFeature = features[0]; + if (!clusterFeature) return; + const clusterId = clusterFeature.properties?.['cluster_id']; + const src = map.getSource(SOURCE_ID) as mapboxgl.GeoJSONSource; + if (typeof clusterId === 'number') { + src.getClusterExpansionZoom( + clusterId, + (err: Error | null | undefined, zoom: number | null | undefined) => { + if (err || zoom == null) return; + const geom = clusterFeature.geometry; + if (geom.type === 'Point') { + map.easeTo({ center: geom.coordinates as [number, number], zoom }); + } + }, + ); + } + }); + + // Initial fetch + listen to viewport changes. + void fetchParks(); + }); + + map.on('moveend', () => { + void fetchParks(); + }); + + return () => { + fetchAbortRef.current?.abort(); + map.remove(); + mapRef.current = null; + }; + // We intentionally do NOT depend on includeOsmRaw — the ref-based + // approach avoids tearing down the map on every prop tick. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [apiBase]); + + // Sync mapStyle (theme switch) without rebuilding the map. + React.useEffect(() => { + const map = mapRef.current; + if (!map) return; + map.setStyle(mapStyle); + }, [mapStyle]); + + const hasToken = typeof process !== 'undefined' && process.env['NEXT_PUBLIC_MAPBOX_TOKEN']; + + return ( +
+
+ {!hasToken && ( +
+ Thiết lập NEXT_PUBLIC_MAPBOX_TOKEN để hiển thị bản đồ +
+ )} +
+ ); +} diff --git a/apps/web/lib/khu-cong-nghiep-api.ts b/apps/web/lib/khu-cong-nghiep-api.ts index 36ac607..edfbc91 100644 --- a/apps/web/lib/khu-cong-nghiep-api.ts +++ b/apps/web/lib/khu-cong-nghiep-api.ts @@ -233,6 +233,46 @@ export interface SearchIndustrialParksParams { limit?: number; } +// ─── OSM Admin Types ──────────────────────────────────── + +export interface OsmPendingItem { + id: string; + slug: string; + name: string; + nameEn: string | null; + province: string; + district: string; + region: string; + status: string; + /** OSM relation/way/node id, serialised as string (BigInt). */ + osmId: string; + osmType: 'NODE' | 'WAY' | 'RELATION' | null; + /** Raw OSM tags object — varies wildly per row. */ + osmTags: Record | null; + totalAreaHa: number; + developer: string; + operator: string | null; + osmLocked: boolean; + lastSyncedAt: string | null; + latitude: number | null; + longitude: number | null; +} + +export interface OsmPendingResult { + data: OsmPendingItem[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface ListOsmPendingParams { + page?: number; + limit?: number; + q?: string; + province?: string; +} + // ─── Labels ───────────────────────────────────────────── export const PARK_STATUS_LABELS: Record = { @@ -328,4 +368,31 @@ export const industrialApi = { deletePark: (id: string) => apiClient.delete<{ success: boolean }>(`/industrial/parks/${id}`), + + // ─── OSM admin endpoints (ADMIN role only) ─────────── + + listOsmPending: (params: ListOsmPendingParams = {}) => { + const query = new URLSearchParams(); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== '') query.append(key, String(value)); + }); + const qs = query.toString(); + return apiClient.get( + `/industrial/parks/osm/pending${qs ? `?${qs}` : ''}`, + ); + }, + + /** Promote OSM row → public OSM_PROMOTED. Optionally lock fields the admin + * just edited so the next sync run leaves them alone. */ + promoteOsm: (id: string, lockFields: string[] = []) => + apiClient.post<{ id: string }>(`/industrial/parks/${id}/osm/promote`, { + lockFields, + }), + + /** Toggle the row-level OSM lock. When `true`, sync skips this row entirely. */ + lockOsm: (id: string, locked: boolean) => + apiClient.post<{ id: string; locked: boolean }>( + `/industrial/parks/${id}/osm/lock`, + { locked }, + ), }; diff --git a/package.json b/package.json index b149cdd..85ae795 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,9 @@ "@eslint/js": "^9.39.4", "@next/eslint-plugin-next": "^16.2.4", "@playwright/test": "^1.59.1", + "@turf/area": "^7.3.5", + "@turf/centroid": "^7.3.5", + "@types/geojson": "^7946.0.16", "@types/pg": "^8.20.0", "dependency-cruiser": "^17.3.10", "dotenv": "^17.4.1", @@ -71,6 +74,7 @@ "globals": "^17.4.0", "husky": "^9.1.7", "lint-staged": "^16.4.0", + "osmtogeojson": "3.0.0-beta.5", "pg": "^8.20.0", "prettier": "^3.8.1", "prisma": "^7.7.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a58863..0b3ff01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,15 @@ importers: '@playwright/test': specifier: ^1.59.1 version: 1.59.1 + '@turf/area': + specifier: ^7.3.5 + version: 7.3.5 + '@turf/centroid': + specifier: ^7.3.5 + version: 7.3.5 + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 '@types/pg': specifier: ^8.20.0 version: 8.20.0 @@ -63,6 +72,9 @@ importers: lint-staged: specifier: ^16.4.0 version: 16.4.0 + osmtogeojson: + specifier: 3.0.0-beta.5 + version: 3.0.0-beta.5 pg: specifier: ^8.20.0 version: 8.20.0 @@ -1509,6 +1521,10 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/geojson-rewind@0.5.2': + resolution: {integrity: sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==} + hasBin: true + '@mapbox/jsonlint-lines-primitives@2.0.2': resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} engines: {node: '>= 0.6'} @@ -3143,6 +3159,18 @@ packages: cpu: [arm64] os: [win32] + '@turf/area@7.3.5': + resolution: {integrity: sha512-sSn80wPT7XfBIDN3vurCPxhk9W4U8ozS/XImSqeLN8qveTICOxzZkhsGDMp0CuncaN+plWut4a2TdNM7mzZB6Q==} + + '@turf/centroid@7.3.5': + resolution: {integrity: sha512-hkWaqwGFdOn6Tf0EWfn2yn1XZ1FWE1h2C5ZWstDMu/FxYO5DB+YjlmOFPl4K6SmSOEgdV07eK2vDCyPeTHqKGA==} + + '@turf/helpers@7.3.5': + resolution: {integrity: sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg==} + + '@turf/meta@7.3.5': + resolution: {integrity: sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -3616,12 +3644,20 @@ packages: '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 prom-client: ^15.0.0 + '@xmldom/xmldom@0.8.3': + resolution: {integrity: sha512-Lv2vySXypg4nfa51LY1nU8yDAGo/5YwF+EY/rUZgIbfvwVARcd67ttCM8SMsTeJy51YhHYavEq+FS6R0hW9PFQ==} + engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version + '@xtuc/ieee754@1.2.0': resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + JSONStream@0.8.0: + resolution: {integrity: sha512-PiV28BpoUorz9kKFwRbD7+wg0t/k0ITHKn0DgCU44YZ/GaGAZRPt9q5PzoifC85gE55SEPIdMu0Labfxevj8cw==} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -4198,6 +4234,9 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -4413,9 +4452,15 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + domelementtype@1.3.1: + resolution: {integrity: sha512-BSKB+TSpMpFI/HOxCNr1O8aMOTZ8hT3pM3GQ0w/mWRmkhEDSFJkkyzz4XQsBV44BChwGkrDfMyjVD0eA2aFV3w==} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domhandler@2.2.1: + resolution: {integrity: sha512-MFFBQFGkyTuNe3vL9WEw9JdlCwIoBYpOGESLeZAvc/jClYNsOl6P1KzevJbWg76GovdEycfR7/2/Ra7NnqtMKw==} + domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} @@ -4423,6 +4468,9 @@ packages: dompurify@3.4.0: resolution: {integrity: sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg==} + domutils@1.3.0: + resolution: {integrity: sha512-1UdPmldjSGewOuWE40YYFZB1Q4im4LZoCMXGYeTeLz3R9hvxrDYJPRcPHXR4yBbubQebgGNCY2hwpJxmAiUMzQ==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -4944,6 +4992,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geojson-numeric@0.2.1: + resolution: {integrity: sha512-rvItMp3W7pe16o2EQTnRw54v6WHdiE4bYjUsdr3FZskFb6oPC7gjLe4zginP+Wd1B/HLl2acTukfn16Lmwn7lg==} + hasBin: true + geojson-vt@4.0.2: resolution: {integrity: sha512-AV9ROqlNqoZEIJGfm1ncNjEXfkz2hdFlZf0qkVfmkwdKa8vj7H16YUOT81rJw1rdFhyEDlN2Tds91p/glzbl5A==} @@ -4970,6 +5022,10 @@ packages: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} + get-stream@6.0.1: + resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + engines: {node: '>=10'} + get-tsconfig@4.13.7: resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} @@ -5110,6 +5166,9 @@ packages: htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@3.5.1: + resolution: {integrity: sha512-9ouaQ6sjVJZS4NhPC65zNm2JCJotiH6BVm6iFvI90hRcsIEISMrgjqMUrPpU9G1VS4vTspH4dyaqSRf6JLQPbg==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -5290,6 +5349,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + isarray@0.0.1: + resolution: {integrity: sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -5376,6 +5438,10 @@ packages: jsonfile@6.2.0: resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==} + jsonparse@0.0.5: + resolution: {integrity: sha512-fw7Q/8gFR8iSekUi9I+HqWIap6mywuoe7hQIg3buTVjuZgALKj4HAmm0X6f+TaL4c9NJbvyFQdaI2ppr5p6dnQ==} + engines: {'0': node >= 0.2.0} + jsonwebtoken@9.0.3: resolution: {integrity: sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==} engines: {node: '>=12', npm: '>=6'} @@ -5865,6 +5931,9 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + optimist@0.3.7: + resolution: {integrity: sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ==} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -5873,6 +5942,14 @@ packages: resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} engines: {node: '>=10'} + osm-polygon-features@0.9.2: + resolution: {integrity: sha512-5zNEFCq+G6X2TDkqbKYLF1+GtWVCCLA8zX+FVhSogsiTRsGquyaGRy5cYNW4BE3ci0MKOLvNTkFNsjsCNtgz0A==} + + osmtogeojson@3.0.0-beta.5: + resolution: {integrity: sha512-izvaUWnunrYvMB4LB0ZN15O1+g90c628yHS4SeSR3daVSBF9vdTHL7iVHfg0wEr1uEYjQ+lMJHCiYFusL5yKVg==} + engines: {node: '>=0.5'} + hasBin: true + otplib@13.4.0: resolution: {integrity: sha512-RUcYcRMCgRWhUE/XabRppXpUwCwaWBNHe5iPXhdvP8wwDGpGpsIf/kxX/ec3zFsOaM1Oq8lEhUqDwk6W7DHkwg==} @@ -5986,6 +6063,10 @@ packages: pause@0.0.1: resolution: {integrity: sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==} + pbf@3.3.0: + resolution: {integrity: sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==} + hasBin: true + pbf@4.0.1: resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} hasBin: true @@ -6337,6 +6418,9 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + readable-stream@1.1.14: + resolution: {integrity: sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -6720,6 +6804,9 @@ packages: resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} engines: {node: '>=20'} + string_decoder@0.10.31: + resolution: {integrity: sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -6893,9 +6980,18 @@ packages: resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==} engines: {node: '>=20'} + through@2.2.7: + resolution: {integrity: sha512-JIR0m0ybkmTcR8URann+HbwKmodP+OE8UCbsifQDYMLD5J3em1Cdn3MYPpbEd5elGDwmP98T+WbqP/tvzA5Mjg==} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-osmpbf@0.1.0: + resolution: {integrity: sha512-Sl0xuDdM0+bnrYPhTAWnQ5eui8+2cpYCnsBxq0EFR1/IgmfB7+FiC23I8aa7tdP4AjaWvBUMK34kfXdY6C1LCQ==} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -7306,6 +7402,10 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@0.0.3: + resolution: {integrity: sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw==} + engines: {node: '>=0.4.0'} + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -8738,6 +8838,11 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/geojson-rewind@0.5.2': + dependencies: + get-stream: 6.0.1 + minimist: 1.2.8 + '@mapbox/jsonlint-lines-primitives@2.0.2': {} '@mapbox/mapbox-gl-supported@3.0.0': {} @@ -10519,6 +10624,31 @@ snapshots: '@turbo/windows-arm64@2.9.4': optional: true + '@turf/area@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/meta': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/centroid@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@turf/meta': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/helpers@7.3.5': + dependencies: + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/meta@7.3.5': + dependencies: + '@turf/helpers': 7.3.5 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -11103,10 +11233,17 @@ snapshots: '@nestjs/common': 11.1.18(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) prom-client: 15.1.3 + '@xmldom/xmldom@0.8.3': {} + '@xtuc/ieee754@1.2.0': {} '@xtuc/long@4.2.2': {} + JSONStream@0.8.0: + dependencies: + jsonparse: 0.0.5 + through: 2.2.7 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -11640,6 +11777,8 @@ snapshots: core-js@3.49.0: optional: true + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -11834,8 +11973,14 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + domelementtype@1.3.1: {} + domelementtype@2.3.0: {} + domhandler@2.2.1: + dependencies: + domelementtype: 1.3.1 + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 @@ -11845,6 +11990,10 @@ snapshots: '@types/trusted-types': 2.0.7 optional: true + domutils@1.3.0: + dependencies: + domelementtype: 1.3.1 + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -12504,6 +12653,11 @@ snapshots: gensync@1.0.0-beta.2: {} + geojson-numeric@0.2.1: + dependencies: + concat-stream: 2.0.0 + optimist: 0.3.7 + geojson-vt@4.0.2: {} get-caller-file@2.0.5: {} @@ -12534,6 +12688,8 @@ snapshots: dependencies: pump: 3.0.4 + get-stream@6.0.1: {} + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -12706,6 +12862,13 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + htmlparser2@3.5.1: + dependencies: + domelementtype: 1.3.1 + domhandler: 2.2.1 + domutils: 1.3.0 + readable-stream: 1.1.14 + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -12881,6 +13044,8 @@ snapshots: is-unicode-supported@0.1.0: {} + isarray@0.0.1: {} + isexe@2.0.0: {} iterare@1.2.1: {} @@ -12966,6 +13131,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + jsonparse@0.0.5: {} + jsonwebtoken@9.0.3: dependencies: jws: 4.0.1 @@ -13462,6 +13629,10 @@ snapshots: dependencies: mimic-function: 5.0.1 + optimist@0.3.7: + dependencies: + wordwrap: 0.0.3 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -13483,6 +13654,22 @@ snapshots: strip-ansi: 6.0.1 wcwidth: 1.0.1 + osm-polygon-features@0.9.2: {} + + osmtogeojson@3.0.0-beta.5: + dependencies: + '@mapbox/geojson-rewind': 0.5.2 + '@xmldom/xmldom': 0.8.3 + JSONStream: 0.8.0 + concat-stream: 2.0.0 + geojson-numeric: 0.2.1 + htmlparser2: 3.5.1 + optimist: 0.3.7 + osm-polygon-features: 0.9.2 + tiny-osmpbf: 0.1.0 + optionalDependencies: + '@types/geojson': 7946.0.16 + otplib@13.4.0: dependencies: '@otplib/core': 13.4.0 @@ -13603,6 +13790,11 @@ snapshots: pause@0.0.1: {} + pbf@3.3.0: + dependencies: + ieee754: 1.2.1 + resolve-protobuf-schema: 2.1.0 + pbf@4.0.1: dependencies: resolve-protobuf-schema: 2.1.0 @@ -13987,6 +14179,13 @@ snapshots: dependencies: pify: 2.3.0 + readable-stream@1.1.14: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 0.0.1 + string_decoder: 0.10.31 + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -14475,6 +14674,8 @@ snapshots: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 + string_decoder@0.10.31: {} + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -14706,8 +14907,17 @@ snapshots: dependencies: real-require: 0.2.0 + through@2.2.7: {} + + tiny-inflate@1.0.3: {} + tiny-invariant@1.3.3: {} + tiny-osmpbf@0.1.0: + dependencies: + pbf: 3.3.0 + tiny-inflate: 1.0.3 + tinybench@2.9.0: {} tinyexec@1.1.1: {} @@ -15176,6 +15386,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@0.0.3: {} + wordwrap@1.0.0: {} wrap-ansi@6.2.0: diff --git a/scripts/sync-osm-industrial-parks.ts b/scripts/sync-osm-industrial-parks.ts new file mode 100644 index 0000000..9dc15bc --- /dev/null +++ b/scripts/sync-osm-industrial-parks.ts @@ -0,0 +1,440 @@ +/** + * OSM → goodgo Industrial Park bulk sync (PR 2/4 of OSM-sync project). + * + * Usage: + * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ + * pnpm tsx scripts/sync-osm-industrial-parks.ts [--dry-run] [--chunk=north] + * + * Flags: + * --dry-run Fetch from Overpass + show counts, don't write to DB. + * --chunk=NAME Process only one chunk (north|northCentral|southCentral|south). + * Default: process all four. + * + * Strategy: + * • Vietnam is split into 4 horizontal bbox slices (Overpass times out + * on the whole country at once). + * • For each chunk we ask Overpass for `landuse=industrial` ways + + * relations + nodes within the bbox, with inline geometry. + * • osmtogeojson normalises the OSM JSON into a GeoJSON FeatureCollection. + * • For each Feature we upsert a row keyed on `osmId`. The schema + * guarantees uniqueness via the constraint added in PR 1. + * • PostGIS columns (`location` Point + `boundary` MultiPolygon) are + * written via raw SQL because Prisma cannot manage Geometry types. + * • Nodes get `location` only — no boundary. + * • Conflict resolution: respect `osmLocked` and `lockedFields` from PR 1. + */ +import 'dotenv/config'; +import { createId } from '@paralleldrive/cuid2'; +import area from '@turf/area'; +import centroid from '@turf/centroid'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { type Prisma, PrismaClient } from '@prisma/client'; +import type { Feature, MultiPolygon, Polygon, Point } from 'geojson'; +import osmtogeojson from 'osmtogeojson'; +import pg from 'pg'; + +const generateCuid = (): Promise => Promise.resolve(createId()); + +// ─── Setup ──────────────────────────────────────────────────────────────── +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +const OVERPASS_URL = + process.env['OVERPASS_URL'] ?? 'https://overpass-api.de/api/interpreter'; + +interface BBox { + south: number; + west: number; + north: number; + east: number; +} + +/** Vietnam split horizontally — Overpass timeouts at country scale. */ +const CHUNKS: Record = { + north: { south: 19.0, west: 102.0, north: 23.5, east: 110.0 }, + northCentral: { south: 16.5, west: 102.0, north: 19.0, east: 110.0 }, + southCentral: { south: 13.0, west: 102.0, north: 16.5, east: 110.0 }, + south: { south: 8.0, west: 102.0, north: 13.0, east: 110.0 }, +}; + +// ─── CLI flags ──────────────────────────────────────────────────────────── +const argv = process.argv.slice(2); +const dryRun = argv.includes('--dry-run'); +const chunkArg = argv.find((a) => a.startsWith('--chunk='))?.slice('--chunk='.length); + +// ─── Province / region heuristic from centroid ──────────────────────────── + +/** Approximate centroid → region using latitude bands. Good enough as a + * fallback when the OSM tags lack `addr:state`; admin can correct later. */ +function guessRegion(lat: number): 'NORTH' | 'CENTRAL' | 'SOUTH' { + if (lat >= 19) return 'NORTH'; + if (lat >= 13) return 'CENTRAL'; + return 'SOUTH'; +} + +// ─── Helpers ────────────────────────────────────────────────────────────── + +function slugify(name: string, osmId: string): string { + const base = name + .toLowerCase() + .replace(/đ/g, 'd') + // strip Vietnamese diacritics + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60); + return `${base || 'kcn'}-osm-${osmId}`; +} + +interface OverpassResult { + elements: unknown[]; +} + +async function fetchChunk(name: string, bbox: BBox): Promise { + const query = ` + [out:json][timeout:180]; + ( + way["landuse"="industrial"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + relation["landuse"="industrial"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + node["landuse"="industrial"](${bbox.south},${bbox.west},${bbox.north},${bbox.east}); + ); + out body geom; + `; + console.log(` → fetching chunk "${name}" from Overpass…`); + const start = Date.now(); + // Overpass expects the query in a `data=` body field with form-encoding. + const res = await fetch(OVERPASS_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'User-Agent': 'goodgo-osm-sync/1.0 (https://goodgo.vn)', + }, + body: 'data=' + encodeURIComponent(query), + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`Overpass returned ${res.status}: ${body.slice(0, 200)}`); + } + const json = (await res.json()) as OverpassResult; + console.log( + ` ← ${name}: ${json.elements?.length ?? 0} elements in ${( + (Date.now() - start) / + 1000 + ).toFixed(1)}s`, + ); + return json; +} + +// ─── Per-feature upsert ────────────────────────────────────────────────── + +interface ParsedFeature { + osmId: bigint; + osmType: 'NODE' | 'WAY' | 'RELATION'; + osmVersion: number | null; + name: string; + nameEn: string | null; + developer: string; + operator: string | null; + tags: Record; + centroid: { lng: number; lat: number }; + /** GeoJSON Polygon|MultiPolygon as an unparsed JSON string for ST_GeomFromGeoJSON. */ + boundaryGeoJson: string | null; + totalAreaHa: number; + province: string; + district: string; + address: string; +} + +/** OSM tag keys whose Vietnamese-province values we recognise. */ +const VN_PROVINCE_HINTS = ['addr:province', 'addr:state', 'addr:region', 'is_in:province']; + +function parseFeature( + feat: Feature, +): ParsedFeature | null { + const propsRaw = feat.properties as Record | null; + if (!propsRaw) return null; + const idStr = String(propsRaw['id'] ?? ''); + // osmtogeojson encodes id as "way/123", "relation/456", "node/789" + const slashIdx = idStr.indexOf('/'); + if (slashIdx < 0) return null; + const typeRaw = idStr.slice(0, slashIdx).toUpperCase(); + if (typeRaw !== 'NODE' && typeRaw !== 'WAY' && typeRaw !== 'RELATION') { + return null; + } + const osmType = typeRaw as 'NODE' | 'WAY' | 'RELATION'; + const osmId = BigInt(idStr.slice(slashIdx + 1)); + const tagsRaw = propsRaw['tags']; + const tags: Record = + tagsRaw && typeof tagsRaw === 'object' + ? (tagsRaw as Record) + : (propsRaw as Record); + + const name = tags['name:vi'] ?? tags['name'] ?? null; + // Skip purely unnamed industrial polygons — too noisy for our catalog. + if (!name) return null; + + const operator = tags['operator'] ?? null; + const developer = operator ?? tags['operator:wikidata'] ?? 'Chưa xác định'; + + // Derive centroid + (optional) boundary GeoJSON. + let cLng: number; + let cLat: number; + let boundaryGeoJson: string | null = null; + let totalAreaHa = 0; + + if (feat.geometry.type === 'Point') { + [cLng, cLat] = feat.geometry.coordinates; + } else { + // Polygon or MultiPolygon + const c = centroid(feat as Feature); + [cLng, cLat] = c.geometry.coordinates; + // Normalise to MultiPolygon for storage in the boundary column. + const geom: MultiPolygon = + feat.geometry.type === 'Polygon' + ? { type: 'MultiPolygon', coordinates: [feat.geometry.coordinates] } + : feat.geometry; + boundaryGeoJson = JSON.stringify(geom); + // turf/area returns m² — convert to ha (1 ha = 10 000 m²). + totalAreaHa = Math.round((area(feat as Feature) / 10000) * 100) / 100; + } + + const province = + VN_PROVINCE_HINTS.map((k) => tags[k]).find(Boolean) ?? + tags['addr:city'] ?? + 'Chưa xác định'; + const district = tags['addr:district'] ?? tags['addr:suburb'] ?? ''; + const address = + tags['addr:full'] ?? + [tags['addr:housenumber'], tags['addr:street']].filter(Boolean).join(' ') ?? + ''; + + const versionRaw = propsRaw['version']; + const osmVersion = typeof versionRaw === 'number' ? versionRaw : null; + + return { + osmId, + osmType, + osmVersion, + name, + nameEn: tags['name:en'] ?? null, + developer, + operator, + tags, + centroid: { lng: cLng, lat: cLat }, + boundaryGeoJson, + totalAreaHa, + province, + district, + address, + }; +} + +interface UpsertStats { + inserted: number; + updated: number; + skipped: number; + locked: number; +} + +async function upsertFeature( + parsed: ParsedFeature, + stats: UpsertStats, +): Promise { + // Cheap pre-flight: if a row already exists with osmLocked=true, skip. + const existing = await prisma.industrialPark.findUnique({ + where: { osmId: parsed.osmId }, + select: { id: true, osmLocked: true, lockedFields: true, dataSource: true }, + }); + + if (existing?.osmLocked) { + stats.locked += 1; + return; + } + + const region = guessRegion(parsed.centroid.lat); + const slug = slugify(parsed.name, parsed.osmId.toString()); + + if (!existing) { + // INSERT path via raw SQL — Prisma's create() can't satisfy the + // `location` PostGIS Geometry NOT NULL column (it's `Unsupported`). + const cuid = await generateCuid(); + const tagsJson = JSON.stringify(parsed.tags); + const boundarySql = parsed.boundaryGeoJson + ? `ST_Multi(ST_GeomFromGeoJSON('${parsed.boundaryGeoJson.replace(/'/g, "''")}'))` + : 'NULL'; + // Use a parameterised statement for safety + ST_* exprs inlined. + await prisma.$executeRawUnsafe( + ` + INSERT INTO "IndustrialPark" ( + id, name, "nameEn", slug, developer, operator, status, + location, address, district, province, region, + "totalAreaHa", "leasableAreaHa", "occupancyRate", "remainingAreaHa", + "tenantCount", "targetIndustries", + "osmId", "osmType", "osmVersion", "osmTags", + "dataSource", "isPublic", "lastSyncedAt", "createdAt", "updatedAt", + boundary + ) VALUES ( + $1, $2, $3, $4, $5, $6, $7::"IndustrialParkStatus", + ST_SetSRID(ST_MakePoint($8, $9), 4326), $10, $11, $12, $13::"VietnamRegion", + $14, $15, $16, $17, + $18, $19::text[], + $20::bigint, $21::"IndustrialParkOsmType", $22, $23::jsonb, + $24::"IndustrialParkDataSource", $25, $26, NOW(), NOW(), + ${boundarySql} + ) + `, + cuid, + parsed.name, + parsed.nameEn, + slug, + parsed.developer, + parsed.operator, + 'OPERATIONAL', + parsed.centroid.lng, + parsed.centroid.lat, + parsed.address, + parsed.district, + parsed.province, + region, + parsed.totalAreaHa, + parsed.totalAreaHa, + 0, + parsed.totalAreaHa, + 0, + [], + parsed.osmId.toString(), + parsed.osmType, + parsed.osmVersion, + tagsJson, + 'OSM', + false, + new Date(), + ); + stats.inserted += 1; + } else { + // UPDATE path. Honour `lockedFields` — preserve listed columns. + const locked = new Set(existing.lockedFields); + const data: Prisma.IndustrialParkUpdateInput = { + osmTags: parsed.tags as Prisma.InputJsonValue, + osmVersion: parsed.osmVersion, + lastSyncedAt: new Date(), + }; + if (!locked.has('name')) data.name = parsed.name; + if (!locked.has('nameEn')) data.nameEn = parsed.nameEn; + if (!locked.has('operator')) data.operator = parsed.operator; + if (!locked.has('developer')) data.developer = parsed.developer; + if (!locked.has('address')) data.address = parsed.address; + if (!locked.has('district')) data.district = parsed.district; + if (!locked.has('province')) data.province = parsed.province; + if (!locked.has('region')) data.region = region; + if (!locked.has('totalAreaHa') && parsed.totalAreaHa > 0) { + data.totalAreaHa = parsed.totalAreaHa; + } + await prisma.industrialPark.update({ + where: { osmId: parsed.osmId }, + data, + }); + stats.updated += 1; + } + + // For UPDATE path, also refresh the geometry columns (INSERT already + // wrote them inline). Skip for fresh INSERTs we just wrote. + if (existing) { + if (parsed.boundaryGeoJson != null) { + await prisma.$executeRaw` + UPDATE "IndustrialPark" + SET + location = ST_SetSRID(ST_MakePoint(${parsed.centroid.lng}, ${parsed.centroid.lat}), 4326), + boundary = ST_Multi(ST_GeomFromGeoJSON(${parsed.boundaryGeoJson})) + WHERE "osmId" = ${parsed.osmId} + `; + } else { + await prisma.$executeRaw` + UPDATE "IndustrialPark" + SET location = ST_SetSRID(ST_MakePoint(${parsed.centroid.lng}, ${parsed.centroid.lat}), 4326) + WHERE "osmId" = ${parsed.osmId} + `; + } + } +} + +// ─── Main ───────────────────────────────────────────────────────────────── + +async function main() { + const chunkNames = chunkArg + ? [chunkArg] + : (Object.keys(CHUNKS) as Array); + const totalStats: UpsertStats = { inserted: 0, updated: 0, skipped: 0, locked: 0 }; + let parseSkipped = 0; + + console.log( + `🌍 OSM industrial-park sync — chunks: ${chunkNames.join(', ')} ` + + `(dry-run: ${dryRun})\n`, + ); + + for (const chunkName of chunkNames) { + const bbox = CHUNKS[chunkName as keyof typeof CHUNKS]; + if (!bbox) { + console.warn(` ⚠️ unknown chunk "${chunkName}" — skipping`); + continue; + } + const overpass = await fetchChunk(chunkName, bbox); + // osmtogeojson is loosely typed; it accepts the raw Overpass JSON. + const fc = osmtogeojson(overpass) as unknown as { + features: Feature[]; + }; + const parsed: ParsedFeature[] = []; + for (const f of fc.features) { + const p = parseFeature(f); + if (p) parsed.push(p); + else parseSkipped += 1; + } + console.log( + ` ▸ ${chunkName}: ${parsed.length} named industrial parks ` + + `(skipped ${fc.features.length - parsed.length} unnamed/malformed)`, + ); + + if (dryRun) { + console.log( + ` ▸ first 3 names: ${parsed + .slice(0, 3) + .map((p) => p.name) + .join(', ')}`, + ); + continue; + } + + for (const p of parsed) { + try { + await upsertFeature(p, totalStats); + } catch (err) { + totalStats.skipped += 1; + console.warn( + ` ⚠️ upsert failed for ${p.osmType.toLowerCase()}/${p.osmId}: ` + + `${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + + console.log('\n✓ Sync complete.'); + console.log( + ` inserted: ${totalStats.inserted} ` + + `· updated: ${totalStats.updated} ` + + `· locked-skipped: ${totalStats.locked} ` + + `· errors: ${totalStats.skipped} ` + + `· parse-skipped: ${parseSkipped}`, + ); +} + +main() + .catch((err) => { + console.error('❌ Sync failed:', err); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + await pool.end(); + });