Add three new NestJS modules following DDD/CQRS architecture: - Industrial: KCN (industrial park) management with PostGIS geo queries, Typesense search, and market statistics - Transfer: Furniture/premises transfer listings with AI-powered price estimation and depreciation modeling - Reports: Async AI report generation via BullMQ with Claude narrative service, PDF generation, and macro data integration Includes Prisma schema models, migrations, seed scripts, and app.module wiring with BullMQ Redis config. Co-Authored-By: Paperclip <noreply@paperclip.ing>
1295 lines
37 KiB
Plaintext
1295 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[]
|
|
|
|
@@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[]
|
|
|
|
// --- 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
|
|
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])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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?
|
|
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
|
|
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
|
|
}
|
|
|
|
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])
|
|
}
|