From f222611fcf20d176e1e153885a6100d8fae3b9d5 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 29 Apr 2026 16:46:50 +0700 Subject: [PATCH] fix(api,web): runtime fixes found during E2E + DB seed repair MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit API bootstrap fixes (DI wiring): - analytics.module: add forwardRef(() => AdminModule) to import AI_CONFIG_PROVIDER for GetListingAiAdviceHandler + GetProjectAiAdviceHandler - listings.module: add PaymentsModule to imports so PAYMENT_INITIATOR is resolvable by FeatureListingHandler - metrics.module: register 3 missing Prometheus providers that MetricsService injects (READ_MODEL_PROJECTOR_LAG_SECONDS / REFRESH_DURATION / RECONCILIATION_DRIFT_TOTAL) — caused boot failure previously - get-listing-ai-advice.handler: switch LISTING_REPOSITORY import from barrel @modules/listings to direct internal path to break circular reference that made the symbol evaluate as undefined at decorator time - shared.module: comment out broken EVENT_BUS / OutboxService / OutboxRelay providers (depend on @goodgo/contracts-events workspace pkg not yet wired) CSRF middleware: - Rewrite exclude logic as inline path-check inside the middleware itself. Nest 11 + path-to-regexp v8 changed how MiddlewareConsumer.exclude() matches against forRoutes('*') — the previous string patterns silently stopped matching, causing every POST to /auth/login to return 403 CSRF Forbidden. Inlined exempt list strips the /api/v1 prefix and checks against a Set. Admin revenue stats: - admin-stats.queries: use Prisma.sql template fragments for DATE_TRUNC unit ('day'|'month'). Passing the unit as a bind parameter caused Postgres error 42803 (column must appear in GROUP BY) because the planner treats $1 as an opaque scalar and cannot prove SELECT and GROUP BY expressions are equal. Admin audit-log page: - SeverityPill: add ?? 'info' fallback — backend AuditLogEntry does not include a `severity` field, so SEVERITY_CONFIG[undefined] was undefined and .dir threw TypeError, crashing the whole audit-log page. DB seed fixes: - seed.ts: replace Vietnamese enum literals ('Sổ hồng', 'Sổ đỏ') with correct enum keys ('SO_HONG', 'SO_DO') for the LegalStatus column - seed-industrial-parks.ts: gate the standalone main() behind require.main === module so importing the file from seed.ts doesn't immediately close the pg.Pool used by the orchestrator - scripts/seed-industrial-listings.ts: restore from tmp/ stash; was missing from scripts/ causing seed.ts import to fail at startup - migration 20260429010000_add_property_certificate_verified: Property table was missing the certificateVerified column required by seed + Prisma schema Co-Authored-By: Claude Sonnet 4.6 --- .../repositories/admin-stats.queries.ts | 8 +- .../src/modules/analytics/analytics.module.ts | 8 +- .../get-listing-ai-advice.handler.ts | 7 +- .../src/modules/listings/listings.module.ts | 2 + .../api/src/modules/metrics/metrics.module.ts | 21 + .../middleware/csrf.middleware.ts | 48 ++ apps/api/src/modules/shared/shared.module.ts | 21 +- .../[locale]/(admin)/admin/audit-log/page.tsx | 7 +- .../migration.sql | 6 + prisma/seed.ts | 20 +- scripts/seed-industrial-listings.ts | 509 ++++++++++++++++++ scripts/seed-industrial-parks.ts | 8 +- 12 files changed, 645 insertions(+), 20 deletions(-) create mode 100644 prisma/migrations/20260429010000_add_property_certificate_verified/migration.sql create mode 100644 scripts/seed-industrial-listings.ts diff --git a/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts b/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts index 40566a9..9b83366 100644 --- a/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts +++ b/apps/api/src/modules/admin/infrastructure/repositories/admin-stats.queries.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client'; import { type PrismaService } from '@modules/shared'; import { type DashboardStats, @@ -80,7 +81,12 @@ export async function getRevenueStats( return cached.data; } - const truncUnit = groupBy === 'day' ? 'day' : 'month'; + // Postgres can't prove that `DATE_TRUNC($n, ...)` in SELECT and in GROUP BY + // are the same expression when the first argument is a bind parameter — it + // raises "column must appear in the GROUP BY clause" (42803). Inline the + // unit as a raw fragment instead. `groupBy` is already constrained to the + // 'day' | 'month' union so this is safe from injection. + const truncUnit = groupBy === 'day' ? Prisma.sql`'day'` : Prisma.sql`'month'`; const rows = await prisma.$queryRaw` SELECT diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 122657f..8921e57 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -1,6 +1,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { makeCounterProvider, makeHistogramProvider } from '@willsoto/nestjs-prometheus'; +import { AdminModule } from '@modules/admin'; import { ListingsModule } from '@modules/listings'; import { ProjectsModule } from '@modules/projects'; import { GenerateReportHandler } from './application/commands/generate-report/generate-report.handler'; @@ -84,7 +85,12 @@ const EventHandlers = [ ]; @Module({ - imports: [CqrsModule, forwardRef(() => ListingsModule), ProjectsModule], + imports: [ + CqrsModule, + forwardRef(() => ListingsModule), + ProjectsModule, + forwardRef(() => AdminModule), // for AI_CONFIG_PROVIDER (used by AI advice handlers) + ], controllers: [AnalyticsController, AvmController], providers: [ // AI service client diff --git a/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts b/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts index 5cefae0..bc5b2cb 100644 --- a/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts @@ -1,9 +1,14 @@ import { HttpStatus, Inject } from '@nestjs/common'; import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +// Direct internal path: barrel `@modules/listings` exports `ListingsModule` +// first, which transitively imports the analytics handler back here. At +// constructor-decorator evaluation time the barrel has not yet exported +// `LISTING_REPOSITORY`, so DI resolves it as `undefined`. +// eslint-disable-next-line no-restricted-imports -- circular-import workaround; see comment above import { LISTING_REPOSITORY, type IListingRepository, -} from '@modules/listings'; +} from '@modules/listings/domain/repositories/listing.repository'; import { AI_CONFIG_PROVIDER, DomainException, diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index bdb5ad6..49f4f24 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -2,6 +2,7 @@ import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { MulterModule } from '@nestjs/platform-express'; import { AnalyticsModule } from '@modules/analytics'; +import { PaymentsModule } from '@modules/payments'; import { FeatureListingThrottlerGuard } from '@modules/shared'; import { AdminFeatureListingHandler } from './application/commands/admin-feature-listing/admin-feature-listing.handler'; import { BulkUpdateListingsHandler } from './application/commands/bulk-update-listings/bulk-update-listings.handler'; @@ -68,6 +69,7 @@ const EventHandlers = [ imports: [ CqrsModule, forwardRef(() => AnalyticsModule), + PaymentsModule, // for PAYMENT_INITIATOR (used by FeatureListingHandler) MulterModule.register({ limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe }), diff --git a/apps/api/src/modules/metrics/metrics.module.ts b/apps/api/src/modules/metrics/metrics.module.ts index 6b81601..e117624 100644 --- a/apps/api/src/modules/metrics/metrics.module.ts +++ b/apps/api/src/modules/metrics/metrics.module.ts @@ -31,6 +31,9 @@ import { SEARCH_QUERY_DURATION, GOODGO_WS_CONNECTED_CLIENTS, GOODGO_WS_MESSAGES_TOTAL, + READ_MODEL_PROJECTOR_LAG_SECONDS, + READ_MODEL_REFRESH_DURATION_SECONDS, + READ_MODEL_RECONCILIATION_DRIFT_TOTAL, WEB_VITALS_LCP, WEB_VITALS_FCP, WEB_VITALS_CLS, @@ -111,6 +114,24 @@ import { HttpMetricsInterceptor } from './presentation/interceptors/http-metrics labelNames: ['namespace', 'event', 'direction'], }), + // ── Read-Model Metrics (RFC-003) ── + makeGaugeProvider({ + name: READ_MODEL_PROJECTOR_LAG_SECONDS, + help: 'Projector replication lag in seconds, by read model', + labelNames: ['read_model'], + }), + makeHistogramProvider({ + name: READ_MODEL_REFRESH_DURATION_SECONDS, + help: 'Materialized-view refresh duration in seconds', + labelNames: ['read_model'], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], + }), + makeCounterProvider({ + name: READ_MODEL_RECONCILIATION_DRIFT_TOTAL, + help: 'Drift events detected during read-model reconciliation', + labelNames: ['read_model', 'severity'], + }), + // ── Services & Interceptors ── MetricsService, HttpMetricsInterceptor, diff --git a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts index d038e69..ceb8964 100644 --- a/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts +++ b/apps/api/src/modules/shared/infrastructure/middleware/csrf.middleware.ts @@ -8,6 +8,47 @@ const TOKEN_LENGTH = 32; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +/** + * Routes that bootstrap a session (or accept beacons that can't carry a + * CSRF header) and are therefore exempt from the double-submit check. + * Matched against the request path with the API global prefix stripped. + * + * NOTE: We check inside the middleware instead of relying on + * `MiddlewareConsumer.exclude(...)` because Nest 11 + path-to-regexp v8 + * changed how `forRoutes('*')` interacts with prefixed excludes — patterns + * that used to work (e.g. `'auth/login'`) silently no longer match. + */ +const EXEMPT_POST_PATHS = new Set([ + '/auth/login', + '/auth/register', + '/auth/refresh', + '/auth/logout', + '/auth/exchange-token', + '/auth/forgot-password', + '/auth/reset-password', + '/web-vitals', +]); + +const EXEMPT_POST_PREFIXES: ReadonlyArray = [ + '/payments/callback/', +]; + +function stripApiPrefix(url: string): string { + // Only the path matters for matching — drop the query string. + const path = url.split('?')[0] ?? url; + if (path.startsWith('/api/v1')) { + return path.slice('/api/v1'.length) || '/'; + } + return path; +} + +function isExempt(method: string, url: string): boolean { + if (method !== 'POST') return false; + const path = stripApiPrefix(url); + if (EXEMPT_POST_PATHS.has(path)) return true; + return EXEMPT_POST_PREFIXES.some((prefix) => path.startsWith(prefix)); +} + @Injectable() export class CsrfMiddleware implements NestMiddleware { use(req: Request, res: Response, next: NextFunction): void { @@ -17,6 +58,13 @@ export class CsrfMiddleware implements NestMiddleware { return next(); } + // Bootstrap + sendBeacon endpoints — never check, but still plant the + // cookie so the next request from the same client can pass validation. + if (isExempt(req.method, req.originalUrl ?? req.url)) { + this.ensureCsrfCookie(req, res); + return next(); + } + // State-changing methods: validate the double-submit token const cookieToken = req.cookies?.[CSRF_COOKIE] as string | undefined; const headerToken = req.headers[CSRF_HEADER] as string | undefined; diff --git a/apps/api/src/modules/shared/shared.module.ts b/apps/api/src/modules/shared/shared.module.ts index d477186..2bde5dd 100644 --- a/apps/api/src/modules/shared/shared.module.ts +++ b/apps/api/src/modules/shared/shared.module.ts @@ -90,14 +90,29 @@ export class SharedModule implements NestModule { consumer .apply(CsrfMiddleware) .exclude( - { path: 'payments/callback/(.*)', method: RequestMethod.POST }, + // NOTE: Nest 11 + path-to-regexp v8 matches `forRoutes('*')` + // middleware exclude paths against the FULL request URL — i.e. + // including the global prefix `api/v1`. Listing both forms keeps + // the rule resilient if the prefix or matching mode changes. + { path: 'api/v1/payments/callback/*path', method: RequestMethod.POST }, + { path: 'api/v1/auth/login', method: RequestMethod.POST }, + { path: 'api/v1/auth/register', method: RequestMethod.POST }, + { path: 'api/v1/auth/refresh', method: RequestMethod.POST }, + { path: 'api/v1/auth/exchange-token', method: RequestMethod.POST }, + { path: 'api/v1/auth/logout', method: RequestMethod.POST }, + { path: 'api/v1/auth/forgot-password', method: RequestMethod.POST }, + { path: 'api/v1/auth/reset-password', method: RequestMethod.POST }, + { path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers + // Legacy controller-relative forms (kept for older path-matching modes). { path: 'auth/login', method: RequestMethod.POST }, { path: 'auth/register', method: RequestMethod.POST }, { path: 'auth/refresh', method: RequestMethod.POST }, { path: 'auth/exchange-token', method: RequestMethod.POST }, { path: 'auth/logout', method: RequestMethod.POST }, - { path: 'api/v1/web-vitals', method: RequestMethod.POST }, // sendBeacon cannot send CSRF headers - { path: 'web-vitals', method: RequestMethod.POST }, // middleware exclude uses controller-relative path + { path: 'auth/forgot-password', method: RequestMethod.POST }, + { path: 'auth/reset-password', method: RequestMethod.POST }, + { path: 'web-vitals', method: RequestMethod.POST }, + { path: 'payments/callback/*path', method: RequestMethod.POST }, ) .forRoutes('*'); } diff --git a/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx b/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx index c5ab258..e581e85 100644 --- a/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx +++ b/apps/web/app/[locale]/(admin)/admin/audit-log/page.tsx @@ -41,8 +41,11 @@ const MODULE_LABELS: Record = { moderation: 'Kiểm duyệt', }; -function SeverityPill({ severity }: { severity: AuditLogItem['severity'] }) { - const cfg = SEVERITY_CONFIG[severity]; +function SeverityPill({ severity }: { severity: AuditLogItem['severity'] | undefined }) { + // The backend doesn't always populate `severity` (only the moderation + // audit log enriches it). Fall back to `info` so the pill renders rather + // than crashing the whole page when an entry omits the field. + const cfg = SEVERITY_CONFIG[severity ?? 'info'] ?? SEVERITY_CONFIG.info; return ; } diff --git a/prisma/migrations/20260429010000_add_property_certificate_verified/migration.sql b/prisma/migrations/20260429010000_add_property_certificate_verified/migration.sql new file mode 100644 index 0000000..003b894 --- /dev/null +++ b/prisma/migrations/20260429010000_add_property_certificate_verified/migration.sql @@ -0,0 +1,6 @@ +-- Add Property.certificateVerified flag — true when the listing's owner has +-- uploaded an ownership certificate (red book / pink book) that has passed +-- moderation. Defaults to false so existing rows keep current behaviour. + +ALTER TABLE "Property" + ADD COLUMN "certificateVerified" BOOLEAN NOT NULL DEFAULT false; diff --git a/prisma/seed.ts b/prisma/seed.ts index f06a6b0..b4f028f 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -357,16 +357,16 @@ async function seedProperties() { } const properties: PropSeed[] = [ - { id: 'seed-prop-001', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Vinhomes Central Park 3PN view sông Sài Gòn', description: 'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn tuyệt đẹp. Full nội thất cao cấp Châu Âu, tiện ích 5 sao.', address: '208 Nguyễn Hữu Cảnh', ward: 'Phường 22', district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', lat: 10.7942, lng: 106.7214, areaM2: 108, usableAreaM2: 95, bedrooms: 3, bathrooms: 2, floor: 25, totalFloors: 50, direction: Direction.SOUTHEAST, yearBuilt: 2018, legalStatus: 'Sổ hồng', projectName: 'Vinhomes Central Park', amenities: '["hồ bơi","gym","công viên","siêu thị"]' }, - { id: 'seed-prop-002', propertyType: PropertyType.APARTMENT, title: 'Căn hộ The Sun Avenue 2PN cho thuê gần Metro', description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần tuyến Metro số 1.', address: '28 Mai Chí Thọ', ward: 'An Phú', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7696, lng: 106.7511, areaM2: 76, usableAreaM2: 68, bedrooms: 2, bathrooms: 2, floor: 15, totalFloors: 28, direction: Direction.NORTH, yearBuilt: 2020, legalStatus: 'Sổ hồng', projectName: 'The Sun Avenue', amenities: '["hồ bơi","gym","BBQ"]' }, - { id: 'seed-prop-003', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Thảo Điền 1 trệt 3 lầu compound an ninh', description: 'Nhà phố khu compound an ninh Thảo Điền, sân vườn rộng, gara 2 ô tô.', address: '12 Nguyễn Văn Hưởng', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8033, lng: 106.7391, areaM2: 200, usableAreaM2: 350, bedrooms: 4, bathrooms: 5, floors: 4, direction: Direction.SOUTH, yearBuilt: 2015, legalStatus: 'Sổ hồng', projectName: null, amenities: '["sân vườn","gara ô tô","bảo vệ 24/7"]' }, - { id: 'seed-prop-004', propertyType: PropertyType.LAND, title: 'Đất nền Quận 7 gần Phú Mỹ Hưng thổ cư 100%', description: 'Đất nền thổ cư 100%, sổ riêng từng nền, hẻm ô tô 8m.', address: '56 Huỳnh Tấn Phát', ward: 'Phú Thuận', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7312, lng: 106.7283, areaM2: 120, usableAreaM2: null, bedrooms: null, bathrooms: null, direction: Direction.EAST, yearBuilt: null, legalStatus: 'Sổ đỏ', projectName: null, amenities: null }, - { id: 'seed-prop-005', propertyType: PropertyType.OFFICE, title: 'Văn phòng cho thuê Quận 1 200m² trung tâm Nguyễn Huệ', description: 'Văn phòng hạng B+ trung tâm Quận 1, full nội thất, PCCC, thang máy.', address: '123 Nguyễn Huệ', ward: 'Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', lat: 10.7731, lng: 106.703, areaM2: 200, usableAreaM2: 180, bedrooms: null, bathrooms: 2, floor: 8, totalFloors: 15, direction: Direction.WEST, yearBuilt: 2010, legalStatus: 'Sổ hồng', projectName: null, amenities: '["thang máy","PCCC","bảo vệ 24/7"]' }, - { id: 'seed-prop-006', propertyType: PropertyType.VILLA, title: 'Biệt thự Sala Đại Quang Minh view công viên', description: 'Biệt thự song lập Sala, 230m² đất, 1 trệt 2 lầu 1 áp mái, bể bơi riêng.', address: '10 Mai Chí Thọ', ward: 'An Lợi Đông', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7721, lng: 106.7432, areaM2: 230, usableAreaM2: 420, bedrooms: 5, bathrooms: 6, floors: 3, direction: Direction.NORTHEAST, yearBuilt: 2019, legalStatus: 'Sổ hồng', projectName: 'Sala Đại Quang Minh', amenities: '["bể bơi riêng","sân vườn","gara 3 ô tô"]' }, - { id: 'seed-prop-007', propertyType: PropertyType.SHOPHOUSE, title: 'Shophouse Vạn Phúc City mặt tiền kinh doanh', description: 'Shophouse mặt tiền 30m khu đô thị Vạn Phúc City, 1 trệt 4 lầu.', address: '15 Nguyễn Thị Nhung', ward: 'Hiệp Bình Phước', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8345, lng: 106.7188, areaM2: 100, usableAreaM2: 400, bedrooms: 3, bathrooms: 4, floors: 5, direction: Direction.SOUTH, yearBuilt: 2022, legalStatus: 'Sổ hồng', projectName: 'Vạn Phúc City', amenities: '["mặt tiền kinh doanh","bãi đỗ xe"]' }, - { id: 'seed-prop-008', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Masteri Thảo Điền 1PN full nội thất', description: 'Căn hộ 1PN Masteri Thảo Điền, nội thất cao cấp, view hồ bơi.', address: '159 Xa lộ Hà Nội', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8025, lng: 106.7415, areaM2: 50, usableAreaM2: 45, bedrooms: 1, bathrooms: 1, floor: 12, totalFloors: 40, direction: Direction.NORTHWEST, yearBuilt: 2017, legalStatus: 'Sổ hồng', projectName: 'Masteri Thảo Điền', amenities: '["hồ bơi","gym","sky lounge"]' }, - { id: 'seed-prop-009', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Midtown Phú Mỹ Hưng 2PN giá tốt', description: 'Căn hộ 2PN Midtown The Peak, Phú Mỹ Hưng. Nội thất cơ bản, view đẹp.', address: '12 Nguyễn Lương Bằng', ward: 'Tân Phú', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7285, lng: 106.7195, areaM2: 85, usableAreaM2: 78, bedrooms: 2, bathrooms: 2, floor: 18, totalFloors: 30, direction: Direction.EAST, yearBuilt: 2021, legalStatus: 'Sổ hồng', projectName: 'Midtown Phú Mỹ Hưng', amenities: '["hồ bơi","gym","công viên"]' }, - { id: 'seed-prop-010', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Gò Vấp 1 trệt 2 lầu sổ hồng riêng', description: 'Nhà phố mới xây tại Gò Vấp, 1 trệt 2 lầu, sân thượng, hẻm xe hơi 6m.', address: '88 Nguyễn Oanh', ward: 'Phường 17', district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', lat: 10.8352, lng: 106.6648, areaM2: 65, usableAreaM2: 150, bedrooms: 3, bathrooms: 3, floors: 3, direction: Direction.WEST, yearBuilt: 2024, legalStatus: 'Sổ hồng', projectName: null, amenities: '["sân thượng","ban công"]' }, + { id: 'seed-prop-001', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Vinhomes Central Park 3PN view sông Sài Gòn', description: 'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn tuyệt đẹp. Full nội thất cao cấp Châu Âu, tiện ích 5 sao.', address: '208 Nguyễn Hữu Cảnh', ward: 'Phường 22', district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', lat: 10.7942, lng: 106.7214, areaM2: 108, usableAreaM2: 95, bedrooms: 3, bathrooms: 2, floor: 25, totalFloors: 50, direction: Direction.SOUTHEAST, yearBuilt: 2018, legalStatus: 'SO_HONG' as const, projectName: 'Vinhomes Central Park', amenities: '["hồ bơi","gym","công viên","siêu thị"]' }, + { id: 'seed-prop-002', propertyType: PropertyType.APARTMENT, title: 'Căn hộ The Sun Avenue 2PN cho thuê gần Metro', description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần tuyến Metro số 1.', address: '28 Mai Chí Thọ', ward: 'An Phú', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7696, lng: 106.7511, areaM2: 76, usableAreaM2: 68, bedrooms: 2, bathrooms: 2, floor: 15, totalFloors: 28, direction: Direction.NORTH, yearBuilt: 2020, legalStatus: 'SO_HONG' as const, projectName: 'The Sun Avenue', amenities: '["hồ bơi","gym","BBQ"]' }, + { id: 'seed-prop-003', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Thảo Điền 1 trệt 3 lầu compound an ninh', description: 'Nhà phố khu compound an ninh Thảo Điền, sân vườn rộng, gara 2 ô tô.', address: '12 Nguyễn Văn Hưởng', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8033, lng: 106.7391, areaM2: 200, usableAreaM2: 350, bedrooms: 4, bathrooms: 5, floors: 4, direction: Direction.SOUTH, yearBuilt: 2015, legalStatus: 'SO_HONG' as const, projectName: null, amenities: '["sân vườn","gara ô tô","bảo vệ 24/7"]' }, + { id: 'seed-prop-004', propertyType: PropertyType.LAND, title: 'Đất nền Quận 7 gần Phú Mỹ Hưng thổ cư 100%', description: 'Đất nền thổ cư 100%, sổ riêng từng nền, hẻm ô tô 8m.', address: '56 Huỳnh Tấn Phát', ward: 'Phú Thuận', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7312, lng: 106.7283, areaM2: 120, usableAreaM2: null, bedrooms: null, bathrooms: null, direction: Direction.EAST, yearBuilt: null, legalStatus: 'SO_DO' as const, projectName: null, amenities: null }, + { id: 'seed-prop-005', propertyType: PropertyType.OFFICE, title: 'Văn phòng cho thuê Quận 1 200m² trung tâm Nguyễn Huệ', description: 'Văn phòng hạng B+ trung tâm Quận 1, full nội thất, PCCC, thang máy.', address: '123 Nguyễn Huệ', ward: 'Bến Nghé', district: 'Quận 1', city: 'Hồ Chí Minh', lat: 10.7731, lng: 106.703, areaM2: 200, usableAreaM2: 180, bedrooms: null, bathrooms: 2, floor: 8, totalFloors: 15, direction: Direction.WEST, yearBuilt: 2010, legalStatus: 'SO_HONG' as const, projectName: null, amenities: '["thang máy","PCCC","bảo vệ 24/7"]' }, + { id: 'seed-prop-006', propertyType: PropertyType.VILLA, title: 'Biệt thự Sala Đại Quang Minh view công viên', description: 'Biệt thự song lập Sala, 230m² đất, 1 trệt 2 lầu 1 áp mái, bể bơi riêng.', address: '10 Mai Chí Thọ', ward: 'An Lợi Đông', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.7721, lng: 106.7432, areaM2: 230, usableAreaM2: 420, bedrooms: 5, bathrooms: 6, floors: 3, direction: Direction.NORTHEAST, yearBuilt: 2019, legalStatus: 'SO_HONG' as const, projectName: 'Sala Đại Quang Minh', amenities: '["bể bơi riêng","sân vườn","gara 3 ô tô"]' }, + { id: 'seed-prop-007', propertyType: PropertyType.SHOPHOUSE, title: 'Shophouse Vạn Phúc City mặt tiền kinh doanh', description: 'Shophouse mặt tiền 30m khu đô thị Vạn Phúc City, 1 trệt 4 lầu.', address: '15 Nguyễn Thị Nhung', ward: 'Hiệp Bình Phước', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8345, lng: 106.7188, areaM2: 100, usableAreaM2: 400, bedrooms: 3, bathrooms: 4, floors: 5, direction: Direction.SOUTH, yearBuilt: 2022, legalStatus: 'SO_HONG' as const, projectName: 'Vạn Phúc City', amenities: '["mặt tiền kinh doanh","bãi đỗ xe"]' }, + { id: 'seed-prop-008', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Masteri Thảo Điền 1PN full nội thất', description: 'Căn hộ 1PN Masteri Thảo Điền, nội thất cao cấp, view hồ bơi.', address: '159 Xa lộ Hà Nội', ward: 'Thảo Điền', district: 'Thủ Đức', city: 'Hồ Chí Minh', lat: 10.8025, lng: 106.7415, areaM2: 50, usableAreaM2: 45, bedrooms: 1, bathrooms: 1, floor: 12, totalFloors: 40, direction: Direction.NORTHWEST, yearBuilt: 2017, legalStatus: 'SO_HONG' as const, projectName: 'Masteri Thảo Điền', amenities: '["hồ bơi","gym","sky lounge"]' }, + { id: 'seed-prop-009', propertyType: PropertyType.APARTMENT, title: 'Căn hộ Midtown Phú Mỹ Hưng 2PN giá tốt', description: 'Căn hộ 2PN Midtown The Peak, Phú Mỹ Hưng. Nội thất cơ bản, view đẹp.', address: '12 Nguyễn Lương Bằng', ward: 'Tân Phú', district: 'Quận 7', city: 'Hồ Chí Minh', lat: 10.7285, lng: 106.7195, areaM2: 85, usableAreaM2: 78, bedrooms: 2, bathrooms: 2, floor: 18, totalFloors: 30, direction: Direction.EAST, yearBuilt: 2021, legalStatus: 'SO_HONG' as const, projectName: 'Midtown Phú Mỹ Hưng', amenities: '["hồ bơi","gym","công viên"]' }, + { id: 'seed-prop-010', propertyType: PropertyType.TOWNHOUSE, title: 'Nhà phố Gò Vấp 1 trệt 2 lầu sổ hồng riêng', description: 'Nhà phố mới xây tại Gò Vấp, 1 trệt 2 lầu, sân thượng, hẻm xe hơi 6m.', address: '88 Nguyễn Oanh', ward: 'Phường 17', district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', lat: 10.8352, lng: 106.6648, areaM2: 65, usableAreaM2: 150, bedrooms: 3, bathrooms: 3, floors: 3, direction: Direction.WEST, yearBuilt: 2024, legalStatus: 'SO_HONG' as const, projectName: null, amenities: '["sân thượng","ban công"]' }, ]; for (const p of properties) { diff --git a/scripts/seed-industrial-listings.ts b/scripts/seed-industrial-listings.ts new file mode 100644 index 0000000..f63830e --- /dev/null +++ b/scripts/seed-industrial-listings.ts @@ -0,0 +1,509 @@ +/** + * Seed industrial listings — 12 sample listings across 8 parks. + * + * Usage: npx tsx scripts/seed-industrial-listings.ts + * Idempotent: uses upsert on id. + */ + +import { PrismaPg } from '@prisma/adapter-pg'; +import { + PrismaClient, + IndustrialPropertyType, + IndustrialLeaseType, + IndustrialListingStatus, +} from '@prisma/client'; +import pg from 'pg'; + +const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +interface IndustrialListingSeed { + id: string; + parkId: string; + sellerId: string; + agentId: string | null; + propertyType: IndustrialPropertyType; + leaseType: IndustrialLeaseType; + status: IndustrialListingStatus; + title: string; + description: string; + areaM2: number; + ceilingHeightM: number | null; + floorLoadTonM2: number | null; + columnSpacingM: number | null; + dockCount: number | null; + craneCapacityTon: number | null; + hasMezzanine: boolean; + hasOfficeArea: boolean; + officeAreaM2: number | null; + priceUsdM2: number | null; + pricingUnit: string | null; + totalLeasePrice: number | null; + managementFee: number | null; + depositMonths: number | null; + minLeaseYears: number | null; + maxLeaseYears: number | null; + availableFrom: Date | null; + powerCapacityKva: number | null; + waterSupplyM3Day: number | null; +} + +const now = new Date(); +const oneMonthLater = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000); +const threeMonthsLater = new Date(now.getTime() + 90 * 24 * 60 * 60 * 1000); + +const LISTINGS: IndustrialListingSeed[] = [ + // --- VSIP Bac Ninh (seed-kcn-001) — 2 listings --- + { + id: 'seed-ind-listing-001', + parkId: 'seed-kcn-001', + sellerId: 'seed-seller-001', + agentId: 'seed-agentprofile-001', + propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, + leaseType: IndustrialLeaseType.FACTORY_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Nhà xưởng xây sẵn 3.000m² KCN VSIP Bắc Ninh', + description: 'Nhà xưởng xây sẵn tiêu chuẩn VSIP tại KCN VSIP Bắc Ninh. Kết cấu thép tiền chế, nền bê tông cốt thép chịu tải 3 tấn/m², hệ thống PCCC tự động, điện 3 pha 500kVA.', + areaM2: 3000, + ceilingHeightM: 10, + floorLoadTonM2: 3, + columnSpacingM: 12, + dockCount: 4, + craneCapacityTon: null, + hasMezzanine: true, + hasOfficeArea: true, + officeAreaM2: 200, + priceUsdM2: 5.5, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 16500, + managementFee: 0.7, + depositMonths: 3, + minLeaseYears: 3, + maxLeaseYears: 10, + availableFrom: oneMonthLater, + powerCapacityKva: 500, + waterSupplyM3Day: 50, + }, + { + id: 'seed-ind-listing-002', + parkId: 'seed-kcn-001', + sellerId: 'seed-seller-002', + agentId: null, + propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, + leaseType: IndustrialLeaseType.LAND_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Đất công nghiệp 10.000m² VSIP Bắc Ninh — vị trí đắc địa', + description: 'Lô đất công nghiệp mặt tiền đường chính 40m, gần cổng chính KCN. Phù hợp xây dựng nhà máy sản xuất điện tử, linh kiện.', + areaM2: 10000, + ceilingHeightM: null, + floorLoadTonM2: null, + columnSpacingM: null, + dockCount: null, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: false, + officeAreaM2: null, + priceUsdM2: 90, + pricingUnit: 'usd/m2/year', + totalLeasePrice: 900000, + managementFee: 0.7, + depositMonths: 6, + minLeaseYears: 20, + maxLeaseYears: 50, + availableFrom: now, + powerCapacityKva: null, + waterSupplyM3Day: null, + }, + + // --- Amata Dong Nai (seed-kcn-003) — 2 listings --- + { + id: 'seed-ind-listing-003', + parkId: 'seed-kcn-003', + sellerId: 'seed-seller-001', + agentId: 'seed-agentprofile-002', + propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, + leaseType: IndustrialLeaseType.WAREHOUSE_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Kho xưởng 5.000m² KCN Amata Đồng Nai — sẵn dock container', + description: 'Kho xưởng xây sẵn với 6 dock container, hệ thống kệ pallet, nền chịu tải 5 tấn/m². Thích hợp logistics và phân phối.', + areaM2: 5000, + ceilingHeightM: 12, + floorLoadTonM2: 5, + columnSpacingM: 15, + dockCount: 6, + craneCapacityTon: 5, + hasMezzanine: false, + hasOfficeArea: true, + officeAreaM2: 150, + priceUsdM2: 5.0, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 25000, + managementFee: 0.65, + depositMonths: 3, + minLeaseYears: 2, + maxLeaseYears: 10, + availableFrom: oneMonthLater, + powerCapacityKva: 300, + waterSupplyM3Day: 30, + }, + { + id: 'seed-ind-listing-004', + parkId: 'seed-kcn-003', + sellerId: 'seed-seller-002', + agentId: 'seed-agentprofile-001', + propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, + leaseType: IndustrialLeaseType.FACTORY_LEASE, + status: IndustrialListingStatus.DRAFT, + title: 'Nhà máy sản xuất 8.000m² Amata — cần bàn giao sớm', + description: 'Nhà máy quy mô lớn với 2 bay sản xuất, cầu trục 10 tấn, hệ thống xử lý nước thải riêng. Đang hoàn thiện, dự kiến bàn giao Q3/2026.', + areaM2: 8000, + ceilingHeightM: 14, + floorLoadTonM2: 5, + columnSpacingM: 18, + dockCount: 8, + craneCapacityTon: 10, + hasMezzanine: true, + hasOfficeArea: true, + officeAreaM2: 500, + priceUsdM2: 4.8, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 38400, + managementFee: 0.65, + depositMonths: 6, + minLeaseYears: 5, + maxLeaseYears: 20, + availableFrom: threeMonthsLater, + powerCapacityKva: 1500, + waterSupplyM3Day: 100, + }, + + // --- Nam Dinh Vu (seed-kcn-005) --- + { + id: 'seed-ind-listing-005', + parkId: 'seed-kcn-005', + sellerId: 'seed-seller-001', + agentId: 'seed-agentprofile-003', + propertyType: IndustrialPropertyType.LOGISTICS_CENTER, + leaseType: IndustrialLeaseType.WAREHOUSE_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Trung tâm logistics 15.000m² KCN Nam Đình Vũ — sát cảng biển', + description: 'Trung tâm logistics hiện đại ngay cảng Đình Vũ, phù hợp cho kho ngoại quan, trung chuyển hàng hóa quốc tế. Hệ thống bãi container 5.000m².', + areaM2: 15000, + ceilingHeightM: 12, + floorLoadTonM2: 5, + columnSpacingM: 20, + dockCount: 12, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: true, + officeAreaM2: 300, + priceUsdM2: 4.8, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 72000, + managementFee: 0.6, + depositMonths: 3, + minLeaseYears: 3, + maxLeaseYears: 15, + availableFrom: now, + powerCapacityKva: 800, + waterSupplyM3Day: 60, + }, + + // --- Long Hau (seed-kcn-006) --- + { + id: 'seed-ind-listing-006', + parkId: 'seed-kcn-006', + sellerId: 'seed-seller-002', + agentId: 'seed-agentprofile-002', + propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, + leaseType: IndustrialLeaseType.SUBLEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Kho hàng 2.000m² Long Hậu — cho thuê lại giá tốt', + description: 'Kho hàng cho thuê lại tại KCN Long Hậu, còn 4 năm hợp đồng gốc. Nền epoxy, PCCC đầy đủ, gần cảng Hiệp Phước.', + areaM2: 2000, + ceilingHeightM: 9, + floorLoadTonM2: 3, + columnSpacingM: 10, + dockCount: 2, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: false, + officeAreaM2: null, + priceUsdM2: 4.0, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 8000, + managementFee: 0.5, + depositMonths: 2, + minLeaseYears: 1, + maxLeaseYears: 4, + availableFrom: now, + powerCapacityKva: 200, + waterSupplyM3Day: 15, + }, + + // --- Thang Long II Hung Yen (seed-kcn-011) --- + { + id: 'seed-ind-listing-007', + parkId: 'seed-kcn-011', + sellerId: 'seed-seller-001', + agentId: 'seed-agentprofile-001', + propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, + leaseType: IndustrialLeaseType.FACTORY_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Xưởng sản xuất 4.500m² KCN Thăng Long II — tiêu chuẩn Nhật', + description: 'Nhà xưởng tiêu chuẩn Nhật Bản tại KCN Thăng Long II Hưng Yên. Clean room sẵn, hệ thống AHU, phù hợp sản xuất linh kiện điện tử.', + areaM2: 4500, + ceilingHeightM: 10, + floorLoadTonM2: 3, + columnSpacingM: 12, + dockCount: 4, + craneCapacityTon: null, + hasMezzanine: true, + hasOfficeArea: true, + officeAreaM2: 350, + priceUsdM2: 4.5, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 20250, + managementFee: 0.6, + depositMonths: 3, + minLeaseYears: 3, + maxLeaseYears: 15, + availableFrom: oneMonthLater, + powerCapacityKva: 600, + waterSupplyM3Day: 40, + }, + + // --- Yen Phong Bac Ninh (seed-kcn-012) --- + { + id: 'seed-ind-listing-008', + parkId: 'seed-kcn-012', + sellerId: 'seed-seller-002', + agentId: null, + propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, + leaseType: IndustrialLeaseType.LAND_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Đất công nghiệp 5.000m² Yên Phong — gần Samsung', + description: 'Lô đất công nghiệp cuối cùng tại KCN Yên Phong, liền kề nhà máy Samsung Display. Hạ tầng hoàn chỉnh, phù hợp nhà cung cấp Samsung.', + areaM2: 5000, + ceilingHeightM: null, + floorLoadTonM2: null, + columnSpacingM: null, + dockCount: null, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: false, + officeAreaM2: null, + priceUsdM2: 85, + pricingUnit: 'usd/m2/year', + totalLeasePrice: 425000, + managementFee: 0.6, + depositMonths: 6, + minLeaseYears: 20, + maxLeaseYears: 47, + availableFrom: now, + powerCapacityKva: null, + waterSupplyM3Day: null, + }, + + // --- DEEP C Hai Phong (seed-kcn-016) --- + { + id: 'seed-ind-listing-009', + parkId: 'seed-kcn-016', + sellerId: 'seed-seller-001', + agentId: 'seed-agentprofile-003', + propertyType: IndustrialPropertyType.READY_BUILT_FACTORY, + leaseType: IndustrialLeaseType.FACTORY_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Nhà xưởng xanh 6.000m² DEEP C Hải Phòng — EDGE certified', + description: 'Nhà xưởng đạt chứng chỉ EDGE Green Building, mái solar panels 200kWp, hệ thống thu gom nước mưa. Phù hợp doanh nghiệp ESG.', + areaM2: 6000, + ceilingHeightM: 11, + floorLoadTonM2: 3, + columnSpacingM: 15, + dockCount: 6, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: true, + officeAreaM2: 250, + priceUsdM2: 4.5, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 27000, + managementFee: 0.6, + depositMonths: 3, + minLeaseYears: 3, + maxLeaseYears: 15, + availableFrom: threeMonthsLater, + powerCapacityKva: 700, + waterSupplyM3Day: 50, + }, + + // --- My Phuoc 3 Binh Duong (seed-kcn-017) --- + { + id: 'seed-ind-listing-010', + parkId: 'seed-kcn-017', + sellerId: 'seed-seller-002', + agentId: 'seed-agentprofile-002', + propertyType: IndustrialPropertyType.READY_BUILT_WAREHOUSE, + leaseType: IndustrialLeaseType.WAREHOUSE_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Kho xưởng 3.500m² Mỹ Phước 3 — gần đường Mỹ Phước Tân Vạn', + description: 'Kho xưởng xây sẵn mặt đường nội khu, gần đường Mỹ Phước - Tân Vạn. Phù hợp kho hàng FMCG, logistics e-commerce.', + areaM2: 3500, + ceilingHeightM: 10, + floorLoadTonM2: 3, + columnSpacingM: 12, + dockCount: 4, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: true, + officeAreaM2: 100, + priceUsdM2: 4.8, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 16800, + managementFee: 0.55, + depositMonths: 3, + minLeaseYears: 2, + maxLeaseYears: 10, + availableFrom: oneMonthLater, + powerCapacityKva: 400, + waterSupplyM3Day: 30, + }, + + // --- KTG Nhon Trach (seed-kcn-009) --- + { + id: 'seed-ind-listing-011', + parkId: 'seed-kcn-009', + sellerId: 'seed-seller-001', + agentId: 'seed-agentprofile-001', + propertyType: IndustrialPropertyType.OFFICE_IN_PARK, + leaseType: IndustrialLeaseType.FACTORY_LEASE, + status: IndustrialListingStatus.DRAFT, + title: 'Văn phòng trong KCN KTG Nhơn Trạch 500m²', + description: 'Văn phòng mới xây trong KCN KTG Nhơn Trạch, 2 tầng, điều hòa trung tâm, bãi đỗ xe riêng. Phù hợp văn phòng vùng cho nhà máy lân cận.', + areaM2: 500, + ceilingHeightM: 3.5, + floorLoadTonM2: null, + columnSpacingM: null, + dockCount: null, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: true, + officeAreaM2: 500, + priceUsdM2: 8.0, + pricingUnit: 'usd/m2/month', + totalLeasePrice: 4000, + managementFee: 0.55, + depositMonths: 2, + minLeaseYears: 1, + maxLeaseYears: 5, + availableFrom: now, + powerCapacityKva: 100, + waterSupplyM3Day: 5, + }, + + // --- Chu Lai Quang Nam (seed-kcn-020) --- + { + id: 'seed-ind-listing-012', + parkId: 'seed-kcn-020', + sellerId: 'seed-seller-002', + agentId: 'seed-agentprofile-003', + propertyType: IndustrialPropertyType.INDUSTRIAL_LAND, + leaseType: IndustrialLeaseType.LAND_LEASE, + status: IndustrialListingStatus.ACTIVE, + title: 'Đất KCN Chu Lai 20.000m² — ưu đãi KKTM đặc biệt', + description: 'Lô đất lớn tại KCN Chu Lai, thuộc Khu kinh tế mở Chu Lai với ưu đãi thuế đặc biệt: miễn tiền thuê đất 15 năm, miễn thuế NK toàn bộ. Phù hợp sản xuất ô tô, cơ khí.', + areaM2: 20000, + ceilingHeightM: null, + floorLoadTonM2: null, + columnSpacingM: null, + dockCount: null, + craneCapacityTon: null, + hasMezzanine: false, + hasOfficeArea: false, + officeAreaM2: null, + priceUsdM2: 40, + pricingUnit: 'usd/m2/year', + totalLeasePrice: 800000, + managementFee: 0.35, + depositMonths: 6, + minLeaseYears: 20, + maxLeaseYears: 50, + availableFrom: now, + powerCapacityKva: null, + waterSupplyM3Day: null, + }, +]; + +export async function seedIndustrialListings() { + console.log('🏭 Seeding industrial listings...'); + + for (const l of LISTINGS) { + const isPublished = + l.status === IndustrialListingStatus.ACTIVE || + l.status === IndustrialListingStatus.RESERVED; + + await prisma.industrialListing.upsert({ + where: { id: l.id }, + update: { + title: l.title, + status: l.status, + priceUsdM2: l.priceUsdM2, + totalLeasePrice: l.totalLeasePrice, + }, + create: { + id: l.id, + parkId: l.parkId, + sellerId: l.sellerId, + agentId: l.agentId, + propertyType: l.propertyType, + leaseType: l.leaseType, + status: l.status, + title: l.title, + description: l.description, + areaM2: l.areaM2, + ceilingHeightM: l.ceilingHeightM, + floorLoadTonM2: l.floorLoadTonM2, + columnSpacingM: l.columnSpacingM, + dockCount: l.dockCount, + craneCapacityTon: l.craneCapacityTon, + hasMezzanine: l.hasMezzanine, + hasOfficeArea: l.hasOfficeArea, + officeAreaM2: l.officeAreaM2, + priceUsdM2: l.priceUsdM2, + pricingUnit: l.pricingUnit, + totalLeasePrice: l.totalLeasePrice, + managementFee: l.managementFee, + depositMonths: l.depositMonths, + minLeaseYears: l.minLeaseYears, + maxLeaseYears: l.maxLeaseYears, + availableFrom: l.availableFrom, + powerCapacityKva: l.powerCapacityKva, + waterSupplyM3Day: l.waterSupplyM3Day, + viewCount: Math.floor(Math.random() * 200) + 5, + inquiryCount: Math.floor(Math.random() * 15), + publishedAt: isPublished ? new Date() : null, + }, + }); + console.log(` ✓ ${l.title.slice(0, 60)}...`); + } + + console.log(`🏭 Seeded ${LISTINGS.length} industrial listings.`); +} + +// Run standalone +async function main() { + try { + await seedIndustrialListings(); + } catch (err) { + console.error('Seed error:', err); + process.exit(1); + } finally { + await prisma.$disconnect(); + await pool.end(); + } +} + +if (require.main === module) { + void main(); +} diff --git a/scripts/seed-industrial-parks.ts b/scripts/seed-industrial-parks.ts index fdf4288..cbea277 100644 --- a/scripts/seed-industrial-parks.ts +++ b/scripts/seed-industrial-parks.ts @@ -839,7 +839,9 @@ export async function seedIndustrialParks() { console.log(`🏭 Seeded ${PARKS.length} industrial parks.`); } -// Run standalone +// Run standalone — but ONLY when invoked directly (`tsx scripts/...`). +// When imported by `prisma/seed.ts`, the orchestrator owns the lifecycle +// and we must not end this module's pool prematurely. async function main() { try { await seedIndustrialParks(); @@ -852,4 +854,6 @@ async function main() { } } -main(); +if (require.main === module) { + void main(); +}