feat(industrial): OSM bulk import + bbox map + admin review (PR 2-4/4)
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 10s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 4s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 49s
Deploy / Build API Image (push) Failing after 9s
Deploy / Build Web Image (push) Failing after 4s
Deploy / Build AI Services Image (push) Failing after 6s
E2E Tests / Playwright E2E (push) Failing after 8s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 3s
Security Scanning / Trivy Scan — API Image (push) Failing after 51s
Deploy / Deploy to Staging (push) Has been cancelled
Deploy / Smoke Test Staging (push) Has been cancelled
Deploy / Rollback Staging (push) Has been cancelled
Deploy / Smoke Test Production (push) Has been cancelled
Deploy / Rollback Production (push) Has been cancelled
Deploy / Deploy to Production (push) Has been cancelled
Security Scanning / Trivy Scan — Web Image (push) Failing after 44s
Security Scanning / Trivy Filesystem Scan (push) Has been cancelled
Security Scanning / Security Gate (push) Has been cancelled
Security Scanning / Trivy Scan — AI Services Image (push) Has started running
Pulls every `landuse=industrial` feature from OpenStreetMap into the
IndustrialPark catalog and surfaces it on the public KCN map. Admins can
promote raw OSM rows into the public catalog or lock individual fields
to protect them from the monthly reconciliation sync.
PR 2 — Bulk import script (scripts/sync-osm-industrial-parks.ts):
• Splits Vietnam into 4 chunks (north / northCentral / southCentral /
south) to stay under Overpass 504 timeouts.
• Posts to overpass-api.de with form-encoded body, converts via
osmtogeojson, derives centroid + area via @turf/centroid + @turf/area.
• Upsert keyed on osmId. Honours `osmLocked` (skip row entirely) and
`lockedFields[]` (skip individual columns) so admin edits survive.
• Inserts use $executeRawUnsafe with ST_SetSRID(ST_MakePoint, 4326)
because Prisma can't manage the Unsupported geometry NOT NULL column.
• CLI flags: --dry-run, --chunk=NAME.
PR 3 — Bbox spatial API + Mapbox layer:
• GET /industrial/parks/by-bbox returns a GeoJSON FeatureCollection
filtered by ST_MakeEnvelope. Sends Point-only at zoom < 12,
MultiPolygon outline at zoom >= 12 to keep payloads light.
• Public consumers see MANUAL + OSM_PROMOTED only; admins can pass
includeOsmRaw=true to also see raw OSM imports.
• OsmParkBboxMap component drives Mapbox from viewport moveend with
AbortController-debounced fetches, clusters at zoom < 12, expands
via getClusterExpansionZoom (callback-style API).
• /khu-cong-nghiep page now uses the bbox map in map + split views.
PR 4 — Admin review queue + monthly cron:
• Commands: PromoteOsmPark (OSM → OSM_PROMOTED + isPublic=true,
optional lockFields), LockOsmPark (toggle row-level skip flag).
• Query: ListOsmPending lists rows with dataSource='OSM' for review.
• OsmSyncCronService runs `0 2 1 * *` Asia/Ho_Chi_Minh and spawns
sync-osm-industrial-parks.ts per chunk. Skipped unless
OSM_SYNC_ENABLED=true so dev never accidentally hits Overpass.
• New admin page /admin/industrial/osm-review: searchable table,
promote dialog with quick-pick lock fields (name, developer,
description, etc.) plus a free-text fallback, lock/unlock toggle,
deep-link to openstreetmap.org for verification.
Repository changes:
• PrismaIndustrialParkRepository now filters public queries to
`isPublic = true AND dataSource IN (MANUAL, OSM_PROMOTED)` so raw
OSM rows stay hidden from end users.
• Added *.rdb to .gitignore (Redis dump local artefact).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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<LockOsmParkCommand> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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[] = [],
|
||||
) {}
|
||||
}
|
||||
@@ -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<PromoteOsmParkCommand> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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<GetIndustrialParksByBboxQuery, IndustrialParksGeoCollection>
|
||||
{
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
q: GetIndustrialParksByBboxQuery,
|
||||
): Promise<IndustrialParksGeoCollection> {
|
||||
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<BboxRow[]>(
|
||||
`
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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<ListOsmPendingQuery> {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly logger: LoggerService,
|
||||
) {}
|
||||
|
||||
async execute(q: ListOsmPendingQuery): Promise<ListOsmPendingResult> {
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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}`));
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user