Files
goodgo-platform/prisma/schema.prisma
Ho Ngoc Hai 3be106074d
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 14s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — API Image (push) Failing after 50s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Security Scanning / Trivy Filesystem Scan (push) Failing after 33s
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
feat: add P0/P1/P2 features + Swagger enrichment for MVP completeness
Closes four gaps the Swagger audit flagged as blocking a full MVP demo,
plus a general documentation pass.

P0 — Forgot/Reset password (auth)
- POST /auth/forgot-password (anti-enumeration: always 200)
- POST /auth/reset-password
- Reuses the Redis-OTP pattern from email/phone change; new key prefix
  auth:password_reset_otp with 15-min TTL.
- Emits PasswordResetRequestedEvent; new listener in notifications
  dispatches the existing password.reset email template (otp +
  expiryMinutes variables already in template.service.ts).
- UserEntity gains changePassword(HashedPassword) domain method; reset
  also revokes all refresh tokens for the user.

P0 — Favorites module
- New SavedListing Prisma model (unique(userId, listingId)) with User
  and Listing back-relations; schema pushed via db push since the
  remote DB was out of sync with migration history.
- New apps/api/src/modules/favorites/ module following the reviews
  module's shape (DDD/CQRS: domain repo + Prisma impl + 2 commands
  + 2 queries + controller).
- POST /favorites/:listingId, DELETE /favorites/:listingId,
  GET /favorites (paginated), GET /favorites/:listingId/check. All
  guarded by JwtAuthGuard.
- FavoritesModule wired into AppModule.

P1 — Resend OTP (auth)
- POST /auth/resend-otp for EMAIL_CHANGE | PHONE_CHANGE. Reads the
  pending OTP payload out of Redis and re-emits the original event
  without minting a new code, so TTL semantics stay intact. Password
  reset resend is done by re-POSTing /auth/forgot-password and is
  deliberately not in this enum.

P1 — Agent self-upgrade (agents)
- POST /agents/me/upgrade lets a BUYER/SELLER convert to AGENT. Creates
  an Agent row (isVerified=false) and flips User.role in one
  $transaction. Rejects if already AGENT/ADMIN or if an Agent row
  already exists.

P2 — Swagger enrichment
- @ApiConsumes('multipart/form-data') + body schema on listings media
  upload.
- GET /subscriptions/quota/:metric now enumerates the real metric
  values from METRIC_TO_PLAN_FIELD.
- POST /avm/batch and /analytics/valuation/batch document the max=50
  batch size from their DTO's @ArrayMaxSize.
- GET /admin/dashboard gains a realistic response example schema.
- Admin-gated endpoints in projects/transfer/industrial gain concrete
  400/401/403/404 responses.

Swagger endpoint count: 170 → 178. Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 00:19:37 +07:00

1315 lines
37 KiB
Plaintext

// =============================================================================
// GoodGo Platform — Prisma Schema
// PostgreSQL 16 + PostGIS
// =============================================================================
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
extensions = [postgis]
}
// =============================================================================
// AUTH
// =============================================================================
enum UserRole {
BUYER
SELLER
AGENT
ADMIN
}
enum KYCStatus {
NONE
PENDING
VERIFIED
REJECTED
}
model User {
id String @id @default(cuid())
email String?
emailHash String? @unique
phone String
phoneHash String? @unique
passwordHash String?
fullName String
avatarUrl String?
role UserRole @default(BUYER)
kycStatus KYCStatus @default(NONE)
kycData Json?
isActive Boolean @default(true)
deletedAt DateTime?
deletionScheduledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// MFA fields
totpSecret String? // Encrypted TOTP secret
totpEnabled Boolean @default(false)
totpBackupCodes String[] // Bcrypt-hashed backup codes
totpEnabledAt DateTime?
agent Agent?
listings Listing[]
savedSearches SavedSearch[]
subscription Subscription?
payments Payment[]
reviews Review[]
inquiriesSent Inquiry[]
refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions")
buyerOrders Order[] @relation("BuyerOrders")
sellerOrders Order[] @relation("SellerOrders")
mfaChallenges MfaChallenge[]
transferListings TransferListing[]
reports Report[]
savedListings SavedListing[]
@@index([role])
@@index([kycStatus])
@@index([isActive])
@@index([deletedAt])
@@index([deletionScheduledAt])
@@index([createdAt])
// --- Compound indexes (query optimization) ---
@@index([role, isActive, createdAt(sort: Desc)])
@@index([kycStatus, createdAt])
}
model MfaChallenge {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type String // "totp" | "backup_code"
attemptCount Int @default(0)
maxAttempts Int @default(5)
isVerified Boolean @default(false)
expiresAt DateTime
createdAt DateTime @default(now())
@@index([userId, expiresAt])
@@index([expiresAt])
}
enum OAuthProvider {
GOOGLE
ZALO
}
model RefreshToken {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique
family String
expiresAt DateTime
revokedAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([family])
@@index([expiresAt])
}
model OAuthAccount {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
provider OAuthProvider
providerUserId String
accessToken String?
refreshToken String?
expiresAt DateTime?
profile Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([provider, providerUserId])
@@index([userId])
}
model Agent {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
licenseNumber String?
agency String?
qualityScore Float @default(0)
totalDeals Int @default(0)
responseTimeAvg Int?
bio String?
serviceAreas Json // ["quan-1", "quan-7", "thu-duc"]
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
leads Lead[]
@@index([qualityScore])
@@index([isVerified])
}
// =============================================================================
// PROJECT DEVELOPMENTS
// =============================================================================
enum ProjectDevelopmentStatus {
PLANNING
UNDER_CONSTRUCTION
COMPLETED
HANDOVER
}
model ProjectDevelopment {
id String @id @default(cuid())
name String
slug String @unique
developer String
developerLogo String?
totalUnits Int
completedUnits Int @default(0)
status ProjectDevelopmentStatus @default(PLANNING)
startDate DateTime?
completionDate DateTime?
description String? @db.Text
amenities Json?
masterPlanUrl String?
location Unsupported("geometry(Point, 4326)")
address String
ward String
district String
city String
minPrice BigInt?
maxPrice BigInt?
pricePerM2Range Json?
totalArea Float?
buildingCount Int?
floorCount Int?
unitTypes Json?
media Json?
documents Json?
tags String[]
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
properties Property[]
@@index([status])
@@index([district, city])
@@index([developer])
@@index([location], type: Gist)
@@index([isVerified])
@@index([createdAt])
@@index([district, city, status])
}
// =============================================================================
// LISTINGS
// =============================================================================
enum PropertyType {
APARTMENT
VILLA
TOWNHOUSE
LAND
OFFICE
SHOPHOUSE
}
enum TransactionType {
SALE
RENT
}
enum ListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
RESERVED
SOLD
RENTED
EXPIRED
REJECTED
}
enum Direction {
NORTH
SOUTH
EAST
WEST
NORTHEAST
NORTHWEST
SOUTHEAST
SOUTHWEST
}
model Property {
id String @id @default(cuid())
propertyType PropertyType
title String
description String @db.Text
address String
ward String
district String
city String
location Unsupported("geometry(Point, 4326)")
areaM2 Float
usableAreaM2 Float?
bedrooms Int?
bathrooms Int?
floors Int?
floor Int?
totalFloors Int?
direction Direction?
yearBuilt Int?
legalStatus String?
amenities Json?
nearbyPOIs Json?
metroDistanceM Float?
projectName String?
projectDevelopmentId String?
projectDevelopment ProjectDevelopment? @relation(fields: [projectDevelopmentId], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings Listing[]
valuations Valuation[]
media PropertyMedia[]
// --- Single-column indexes ---
@@index([propertyType])
@@index([district, city])
@@index([location], type: Gist)
@@index([projectDevelopmentId])
// --- Compound indexes (query optimization) ---
@@index([district, propertyType])
@@index([district, city, propertyType])
}
model PropertyMedia {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
url String
type String // "image" | "video"
order Int @default(0)
caption String?
aiTags Json?
createdAt DateTime @default(now())
@@index([propertyId])
}
model Listing {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
agentId String?
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
sellerId String
seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
transactionType TransactionType
status ListingStatus @default(DRAFT)
priceVND BigInt
pricePerM2 Float?
rentPriceMonthly BigInt?
commissionPct Float? @default(2.0)
aiPriceEstimate BigInt?
aiConfidence Float?
moderationScore Float?
moderationNotes String?
viewCount Int @default(0)
saveCount Int @default(0)
inquiryCount Int @default(0)
featuredUntil DateTime?
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
transactions Transaction[]
inquiries Inquiry[]
orders Order[]
priceHistories PriceHistory[]
savedByUsers SavedListing[]
// --- Single-column indexes ---
@@index([status])
@@index([transactionType])
@@index([priceVND])
@@index([sellerId])
@@index([propertyId])
@@index([agentId])
@@index([publishedAt])
@@index([createdAt])
@@index([featuredUntil])
@@index([expiresAt])
// --- Compound indexes (query optimization) ---
@@index([sellerId, status, publishedAt(sort: Desc)])
@@index([agentId, status])
@@index([status, createdAt(sort: Desc)])
@@index([status, publishedAt(sort: Desc)])
@@index([transactionType, status, createdAt(sort: Desc)])
@@index([status, transactionType, priceVND])
}
model PriceHistory {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
oldPrice BigInt
newPrice BigInt
source String @default("manual_update")
changedAt DateTime @default(now())
@@index([listingId, changedAt(sort: Desc)])
}
// =============================================================================
// SEARCH
// =============================================================================
model SavedSearch {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
filters Json
alertEnabled Boolean @default(true)
lastAlertAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
}
model SavedListing {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([userId, listingId])
@@index([userId, createdAt(sort: Desc)])
@@index([listingId])
}
// =============================================================================
// TRANSACTIONS
// =============================================================================
enum TransactionStatus {
INQUIRY
VIEWING_SCHEDULED
OFFER_MADE
DEPOSIT_PAID
CONTRACT_SIGNING
COMPLETED
CANCELLED
}
model Transaction {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
buyerId String
buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id], onDelete: Restrict)
status TransactionStatus @default(INQUIRY)
agreedPrice BigInt?
depositAmount BigInt?
timeline Json?
contractUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
@@index([listingId])
@@index([buyerId])
@@index([status])
}
model Inquiry {
id String @id @default(cuid())
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
message String @db.Text
phone String?
isRead Boolean @default(false)
createdAt DateTime @default(now())
@@index([listingId])
@@index([userId])
@@index([listingId, userId])
}
enum LeadStatus {
NEW
CONTACTED
QUALIFIED
NEGOTIATING
CONVERTED
LOST
}
model Lead {
id String @id @default(cuid())
agentId String
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
name String
phone String
phoneHash String?
email String?
emailHash String?
source String
score Float?
notes Json?
status LeadStatus @default(NEW)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([agentId])
@@index([status])
@@index([phoneHash])
@@index([emailHash])
}
// =============================================================================
// PAYMENTS
// =============================================================================
enum PaymentProvider {
VNPAY
MOMO
ZALOPAY
BANK_TRANSFER
}
enum PaymentStatus {
PENDING
PROCESSING
COMPLETED
FAILED
REFUNDED
}
enum PaymentType {
SUBSCRIPTION
LISTING_FEE
DEPOSIT
FEATURED_LISTING
AUCTION_PAYMENT
}
model Payment {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
transactionId String?
transaction Transaction? @relation(fields: [transactionId], references: [id], onDelete: SetNull)
orderId String?
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
provider PaymentProvider
type PaymentType
amountVND BigInt
status PaymentStatus @default(PENDING)
providerTxId String?
callbackData Json?
idempotencyKey String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, provider, idempotencyKey], name: "Payment_idempotency_unique")
@@index([userId])
@@index([transactionId])
@@index([orderId])
@@index([status])
@@index([providerTxId])
@@index([createdAt])
// --- Compound indexes (query optimization) ---
@@index([userId, status, createdAt(sort: Desc)])
@@index([userId, type, createdAt(sort: Desc)])
}
// =============================================================================
// ORDERS & ESCROW (Auction Settlement)
// =============================================================================
enum OrderStatus {
CREATED
PAYMENT_PENDING
PAYMENT_CONFIRMED
ESCROW_HELD
SHIPPED
DELIVERED
DISPUTE
ESCROW_RELEASED
COMPLETED
CANCELLED
REFUNDED
}
enum EscrowStatus {
PENDING
HELD
RELEASED
REFUNDED
DISPUTED
}
model Order {
id String @id @default(cuid())
buyerId String
buyer User @relation("BuyerOrders", fields: [buyerId], references: [id], onDelete: Restrict)
sellerId String
seller User @relation("SellerOrders", fields: [sellerId], references: [id], onDelete: Restrict)
listingId String
listing Listing @relation(fields: [listingId], references: [id], onDelete: Restrict)
status OrderStatus @default(CREATED)
amountVND BigInt
platformFeeVND BigInt
sellerPayoutVND BigInt
idempotencyKey String? @unique
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
payments Payment[]
escrow Escrow?
@@index([buyerId])
@@index([sellerId])
@@index([listingId])
@@index([status])
@@index([createdAt(sort: Desc)])
}
model Escrow {
id String @id @default(cuid())
orderId String @unique
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
amountVND BigInt
feeVND BigInt
status EscrowStatus @default(PENDING)
heldAt DateTime?
releasedAt DateTime?
disputeReason String? @db.Text
disputedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status])
@@index([orderId])
}
// =============================================================================
// SUBSCRIPTIONS
// =============================================================================
enum PlanTier {
FREE
AGENT_PRO
INVESTOR
ENTERPRISE
}
enum SubscriptionStatus {
ACTIVE
PAST_DUE
CANCELLED
EXPIRED
}
model Plan {
id String @id @default(cuid())
tier PlanTier @unique
name String
priceMonthlyVND BigInt
priceYearlyVND BigInt
maxListings Int?
maxSavedSearches Int?
maxAnalyticsQueries Int?
maxReports Int?
maxMediaUploads Int?
featuredListingsQuota Int?
features Json
isActive Boolean @default(true)
subscriptions Subscription[]
}
model Subscription {
id String @id @default(cuid())
userId String @unique
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
planId String
plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict)
status SubscriptionStatus @default(ACTIVE)
currentPeriodStart DateTime
currentPeriodEnd DateTime
cancelledAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
usageRecords UsageRecord[]
@@index([planId])
@@index([status])
}
model UsageRecord {
id String @id @default(cuid())
subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
metric String
count Int
periodStart DateTime
periodEnd DateTime
@@index([subscriptionId, metric])
}
// =============================================================================
// ANALYTICS
// =============================================================================
model Valuation {
id String @id @default(cuid())
propertyId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
estimatedPrice BigInt
confidence Float
pricePerM2 Float
comparables Json
features Json
modelVersion String
createdAt DateTime @default(now())
@@index([propertyId])
@@index([propertyId, createdAt(sort: Desc)])
}
model MarketIndex {
id String @id @default(cuid())
district String
city String
propertyType PropertyType
period String
medianPrice BigInt
avgPriceM2 Float
totalListings Int
daysOnMarket Float
inventoryLevel Int
absorptionRate Float?
yoyChange Float?
createdAt DateTime @default(now())
@@unique([district, city, propertyType, period])
@@index([city, period])
}
// =============================================================================
// NOTIFICATIONS
// =============================================================================
enum NotificationChannel {
EMAIL
SMS
PUSH
ZALO_OA
}
enum NotificationStatus {
PENDING
SENT
FAILED
DELIVERED
}
model NotificationLog {
id String @id @default(cuid())
userId String
channel NotificationChannel
templateKey String
subject String?
body String @db.Text
metadata Json?
status NotificationStatus @default(PENDING)
errorDetail String?
sentAt DateTime?
readAt DateTime?
createdAt DateTime @default(now())
@@index([userId])
@@index([channel, status])
@@index([templateKey])
@@index([createdAt])
@@index([userId, readAt])
@@index([userId, createdAt(sort: Desc)])
}
model NotificationPreference {
id String @id @default(cuid())
userId String
channel NotificationChannel
eventType String
enabled Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, channel, eventType])
@@index([userId])
}
// =============================================================================
// ADMIN AUDIT LOG
// =============================================================================
enum AdminAction {
LISTING_APPROVED
LISTING_REJECTED
LISTING_BULK_APPROVED
LISTING_BULK_REJECTED
LISTING_FEATURED
LISTING_UNFEATURED
USER_BANNED
USER_UNBANNED
USER_STATUS_UPDATED
KYC_APPROVED
KYC_REJECTED
SUBSCRIPTION_ADJUSTED
}
enum AuditTargetType {
USER
LISTING
SUBSCRIPTION
}
model AdminAuditLog {
id String @id @default(cuid())
action AdminAction
actorId String
targetId String
targetType AuditTargetType
metadata Json?
ipAddress String?
userAgent String?
createdAt DateTime @default(now())
@@index([actorId])
@@index([targetId, targetType])
@@index([action])
@@index([createdAt])
@@index([actorId, createdAt(sort: Desc)])
@@index([action, createdAt(sort: Desc)])
}
// =============================================================================
// NEIGHBORHOOD & POI
// =============================================================================
enum POIType {
SCHOOL
UNIVERSITY
HOSPITAL
CLINIC
METRO_STATION
BUS_STOP
MALL
MARKET
SUPERMARKET
PARK
POLICE_STATION
FIRE_STATION
BANK
ATM
RESTAURANT
CAFE
GYM
PHARMACY
}
model POI {
id String @id @default(cuid())
name String
type POIType
location Unsupported("geometry(Point, 4326)")
address String?
ward String?
district String
city String
osmId String? @unique
metadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([type])
@@index([district, city])
@@index([type, district, city])
@@index([location], type: Gist)
@@index([osmId])
}
model NeighborhoodScore {
id String @id @default(cuid())
district String
city String
educationScore Float // 0-10: schools/universities within 2km
healthcareScore Float // 0-10: hospitals/clinics within 3km
transportScore Float // 0-10: metro/bus within 1km
shoppingScore Float // 0-10: mall/market within 2km
greeneryScore Float // 0-10: parks within 1km
safetyScore Float // 0-10: police/fire stations + safety index
totalScore Float // 0-100: weighted average
poiCounts Json // { education: 12, healthcare: 5, ... }
calculatedAt DateTime @default(now())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([district, city])
@@index([totalScore(sort: Desc)])
@@index([city])
}
// =============================================================================
// REVIEWS
// =============================================================================
model Review {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
targetType String
targetId String
rating Int
comment String? @db.Text
createdAt DateTime @default(now())
@@index([targetType, targetId])
@@index([userId])
@@index([targetType, targetId, createdAt(sort: Desc)])
}
// =============================================================================
// INDUSTRIAL PARKS (KCN)
// =============================================================================
enum IndustrialParkStatus {
PLANNING
UNDER_CONSTRUCTION
OPERATIONAL
FULL
}
enum IndustrialPropertyType {
INDUSTRIAL_LAND
READY_BUILT_FACTORY
READY_BUILT_WAREHOUSE
LOGISTICS_CENTER
OFFICE_IN_PARK
DATA_CENTER
}
enum IndustrialLeaseType {
LAND_LEASE
FACTORY_LEASE
WAREHOUSE_LEASE
SUBLEASE
}
enum IndustrialListingStatus {
DRAFT
ACTIVE
RESERVED
LEASED
EXPIRED
}
enum VietnamRegion {
NORTH
CENTRAL
SOUTH
}
model IndustrialPark {
id String @id @default(cuid())
name String
nameEn String?
slug String @unique
developer String
operator String?
status IndustrialParkStatus @default(PLANNING)
location Unsupported("geometry(Point, 4326)")
address String
district String
province String
region VietnamRegion
totalAreaHa Float
leasableAreaHa Float
occupancyRate Float @default(0) // 0-100
remainingAreaHa Float
tenantCount Int @default(0)
establishedYear Int?
landRentUsdM2Year Float?
rbfRentUsdM2Month Float?
rbwRentUsdM2Month Float?
managementFeeUsd Float?
infrastructure Json? // { electricity, water, wastewater, telecom, roads, fire }
connectivity Json? // { nearestPort, airport, highway, railway, seaport }
incentives Json? // { taxHoliday, importDuty, landRentReduction, specialZone }
targetIndustries String[]
existingTenants Json? // [{ name, country, industry }]
certifications Json? // ["ISO 14001", "Green park"]
media Json?
documents Json?
description String? @db.Text
descriptionEn String? @db.Text
isVerified Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
listings IndustrialListing[]
@@index([status])
@@index([province])
@@index([region])
@@index([developer])
@@index([location], type: Gist)
@@index([isVerified])
@@index([occupancyRate])
@@index([landRentUsdM2Year])
@@index([region, province, status])
@@index([createdAt])
}
model IndustrialListing {
id String @id @default(cuid())
parkId String
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
agentId String?
sellerId String
propertyType IndustrialPropertyType
leaseType IndustrialLeaseType
status IndustrialListingStatus @default(DRAFT)
title String
description String? @db.Text
areaM2 Float
ceilingHeightM Float?
floorLoadTonM2 Float?
columnSpacingM Float?
dockCount Int?
craneCapacityTon Float?
hasMezzanine Boolean @default(false)
hasOfficeArea Boolean @default(false)
officeAreaM2 Float?
priceUsdM2 Float?
pricingUnit String? // "usd/m2/month", "usd/m2/year"
totalLeasePrice Float?
managementFee Float?
depositMonths Int?
minLeaseYears Int?
maxLeaseYears Int?
leaseExpiry DateTime?
availableFrom DateTime?
powerCapacityKva Float?
waterSupplyM3Day Float?
media Json?
viewCount Int @default(0)
inquiryCount Int @default(0)
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([parkId])
@@index([propertyType])
@@index([leaseType])
@@index([status])
@@index([areaM2])
@@index([priceUsdM2])
@@index([sellerId])
@@index([agentId])
@@index([publishedAt])
@@index([parkId, status])
@@index([propertyType, leaseType, status])
@@index([status, publishedAt(sort: Desc)])
}
// =============================================================================
// MESSAGING (buyer ↔ agent / seller in-app chat)
// =============================================================================
enum ConversationStatus {
ACTIVE
ARCHIVED
CLOSED
}
model Conversation {
id String @id @default(cuid())
listingId String?
subject String?
status ConversationStatus @default(ACTIVE)
lastMessage String? @db.Text
lastMessageAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
participants ConversationParticipant[]
messages Message[]
@@index([status])
@@index([lastMessageAt(sort: Desc)])
@@index([listingId])
}
model ConversationParticipant {
id String @id @default(cuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
userId String
unreadCount Int @default(0)
lastReadAt DateTime?
joinedAt DateTime @default(now())
@@unique([conversationId, userId])
@@index([userId])
@@index([conversationId])
}
enum MessageType {
TEXT
IMAGE
FILE
SYSTEM
}
model Message {
id String @id @default(cuid())
conversationId String
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
senderId String
type MessageType @default(TEXT)
content String @db.Text
metadata Json?
editedAt DateTime?
deletedAt DateTime?
createdAt DateTime @default(now())
@@index([conversationId, createdAt])
@@index([senderId])
}
// =============================================================================
// TRANSFER (Furniture + Premises Handover)
// =============================================================================
enum TransferCategory {
FURNITURE // Nội thất (sofa, bàn, tủ, giường)
APPLIANCE // Thiết bị gia dụng (máy lạnh, tủ lạnh, máy giặt)
OFFICE_EQUIPMENT // Thiết bị văn phòng (bàn làm việc, ghế, máy in)
KITCHEN // Bếp + thiết bị bếp
PREMISES // Mặt bằng kinh doanh
FULL_UNIT // Chuyển nhượng trọn bộ (nội thất + mặt bằng)
}
enum TransferCondition {
NEW // Mới (< 6 tháng)
LIKE_NEW // Như mới (6-12 tháng)
GOOD // Tốt (1-3 năm)
FAIR // Khá (3-5 năm)
WORN // Cũ (> 5 năm)
}
enum TransferListingStatus {
DRAFT
PENDING_REVIEW
ACTIVE
RESERVED
SOLD
EXPIRED
REJECTED
CANCELLED
}
enum TransferPricingSource {
MANUAL // Người bán tự định giá
AI_ESTIMATED // AI ước tính dựa trên khấu hao + thương hiệu
NEGOTIABLE // Giá thương lượng
}
model TransferListing {
id String @id @default(cuid())
sellerId String
seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
category TransferCategory
status TransferListingStatus @default(DRAFT)
title String
description String? @db.Text
// Location
address String
ward String?
district String
city String
location Unsupported("geometry(Point, 4326)")
// Pricing
askingPriceVND BigInt
aiEstimatePriceVND BigInt?
aiConfidence Float?
pricingSource TransferPricingSource @default(MANUAL)
isNegotiable Boolean @default(true)
// Premises-specific fields (for PREMISES / FULL_UNIT)
areaM2 Float?
monthlyRentVND BigInt?
depositMonths Int?
remainingLeaseMo Int?
businessType String? // Loại hình kinh doanh hiện tại
footTraffic String? // Mô tả lưu lượng khách
// Metadata
media Json? // [{ url, type, order, caption }]
moderationScore Float?
moderationNotes String?
viewCount Int @default(0)
saveCount Int @default(0)
inquiryCount Int @default(0)
contactPhone String?
contactName String?
featuredUntil DateTime?
expiresAt DateTime?
publishedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
items TransferItem[]
@@index([sellerId])
@@index([category])
@@index([status])
@@index([district, city])
@@index([askingPriceVND])
@@index([location], type: Gist)
@@index([publishedAt])
@@index([createdAt])
@@index([featuredUntil])
@@index([expiresAt])
@@index([category, status, publishedAt(sort: Desc)])
@@index([district, city, category, status])
@@index([status, createdAt(sort: Desc)])
}
model TransferItem {
id String @id @default(cuid())
transferListingId String
transferListing TransferListing @relation(fields: [transferListingId], references: [id], onDelete: Cascade)
name String // Tên sản phẩm (e.g. "Sofa góc L 3m")
brand String? // Thương hiệu
modelName String? // Model / SKU
category TransferCategory
condition TransferCondition
purchaseYear Int? // Năm mua
originalPriceVND BigInt? // Giá mua ban đầu
askingPriceVND BigInt // Giá bán mong muốn
aiEstimatePriceVND BigInt? // AI ước tính
aiConfidence Float?
quantity Int @default(1)
dimensions Json? // { widthCm, heightCm, depthCm, weightKg }
media Json? // [{ url, type, order }]
notes String? @db.Text
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([transferListingId])
@@index([category])
@@index([condition])
@@index([brand])
@@index([askingPriceVND])
@@index([transferListingId, category])
}
// =============================================================================
// AI REPORTS
// =============================================================================
enum ReportType {
RESIDENTIAL_MARKET
INDUSTRIAL_MARKET
DISTRICT_ANALYSIS
INVESTMENT_FEASIBILITY
INDUSTRIAL_LOCATION
PROPERTY_VALUATION
PORTFOLIO
}
enum ReportStatus {
GENERATING
READY
FAILED
}
model Report {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
type ReportType
title String
params Json // Input parameters (city, province, period, etc.)
content Json? // Structured report content (sections, charts data)
pdfUrl String? // MinIO URL to generated PDF
status ReportStatus @default(GENERATING)
errorMsg String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, createdAt(sort: Desc)])
@@index([userId, type])
@@index([status])
}
model MacroeconomicData {
id String @id @default(cuid())
province String
indicator String // gdp, fdi, population, urbanization, labor_force, avg_wage, industrial_output, cpi, mortgage_rate
value Float
unit String // USD, VND, %, persons, etc.
period String // e.g. "2025", "2025-Q4"
source String // GSO, World Bank, SBV
createdAt DateTime @default(now())
@@unique([province, indicator, period])
@@index([province])
@@index([indicator, period])
}
model InfrastructureProject {
id String @id @default(cuid())
name String
province String
category String // metro, highway, airport, port, bridge, industrial_zone
status String // planning, under_construction, completed
investmentVND BigInt?
startDate DateTime?
completionDate DateTime?
description String? @db.Text
impactRadius Float? // km
location Unsupported("geometry(Point, 4326)")?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([province])
@@index([category])
@@index([status])
@@index([province, category])
}