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 */}
+
+
+
+
+
+
+ {/* 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 */}
+
+
+ );
+}
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();
+ });