- Add expiryNotifiedAt column to Listing (migration 20260423100000); atomic UPDATE…RETURNING guards against duplicate notifications across concurrent cron instances - Add ListingExpiringEvent domain event (listing.expiring) - Add ListingExpiryCronService: daily cron at 01:00 UTC; marks expiryNotifiedAt before publishing events (idempotent) - Add ListingExpiringListener: sends EMAIL + Zalo OA via SendNotificationCommand with daysRemaining context - Add listing.expiring Handlebars template (Vietnamese) - Wire cron into ListingsModule, listener into NotificationsModule - Update template.service spec: 17 → 19 keys (listing.expiring + the pre-existing user.phone_login_otp that was missing from assertion) Co-Authored-By: Paperclip <noreply@paperclip.ing>
1570 lines
47 KiB
Plaintext
1570 lines
47 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
|
|
/// Chủ đầu tư dự án BĐS — được admin cấp tài khoản; CRUD dự án của mình và
|
|
/// xem inquiry/lead/analytics cho các dự án đó.
|
|
DEVELOPER
|
|
/// Đơn vị vận hành Khu Công Nghiệp — được admin cấp tài khoản; CRUD KCN và
|
|
/// industrial listings của mình.
|
|
PARK_OPERATOR
|
|
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[]
|
|
/// Dự án BĐS do user này làm chủ đầu tư (role=DEVELOPER).
|
|
ownedProjects ProjectDevelopment[] @relation("ProjectOwner")
|
|
/// KCN do user này vận hành (role=PARK_OPERATOR).
|
|
ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner")
|
|
zaloAccountLink ZaloAccountLink?
|
|
notificationLogs NotificationLog[]
|
|
industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller")
|
|
listingFlagsReported ListingFlag[] @relation("listingFlagsReported")
|
|
|
|
@@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])
|
|
}
|
|
|
|
/// Zalo OA account link — stores the OA-scoped access/refresh tokens for sending
|
|
/// template messages to a linked user via ZNS.
|
|
/// Token fields are AES-256-GCM encrypted at the application layer.
|
|
model ZaloAccountLink {
|
|
id String @id @default(cuid())
|
|
userId String @unique
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
/// Zalo user ID scoped to the Official Account (OA UID, not Social Graph UID)
|
|
zaloUserId String @unique
|
|
/// AES-256-GCM encrypted access token (base64url: iv.tag.ciphertext)
|
|
accessToken String
|
|
/// AES-256-GCM encrypted refresh token (base64url: iv.tag.ciphertext)
|
|
refreshToken String
|
|
expiresAt DateTime
|
|
/// Unix epoch (seconds) of the last user→OA interaction; used for 24-hour window check
|
|
lastInteractAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([zaloUserId])
|
|
@@index([expiresAt])
|
|
@@map("zalo_account_links")
|
|
}
|
|
|
|
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[]
|
|
industrialListings IndustrialListing[] @relation("IndustrialListingAgent")
|
|
|
|
@@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[]
|
|
suitableFor String[] @default([])
|
|
whyThisLocation String? @db.Text
|
|
isVerified Boolean @default(false)
|
|
/// Optional owning developer user (role=DEVELOPER). NULL for projects not
|
|
/// yet assigned to a CĐT account — admin still manages those.
|
|
ownerId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
properties Property[]
|
|
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([status])
|
|
@@index([district, city])
|
|
@@index([developer])
|
|
@@index([location], type: Gist)
|
|
@@index([isVerified])
|
|
@@index([createdAt])
|
|
@@index([district, city, status])
|
|
@@index([ownerId])
|
|
}
|
|
|
|
// =============================================================================
|
|
// LISTINGS
|
|
// =============================================================================
|
|
|
|
enum PropertyType {
|
|
APARTMENT
|
|
VILLA
|
|
TOWNHOUSE
|
|
LAND
|
|
OFFICE
|
|
SHOPHOUSE
|
|
ROOM_RENTAL
|
|
CONDOTEL
|
|
SERVICED_APARTMENT
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
enum Furnishing {
|
|
FULLY_FURNISHED
|
|
BASIC_FURNISHED
|
|
UNFURNISHED
|
|
}
|
|
|
|
enum PropertyCondition {
|
|
NEW
|
|
LIKE_NEW
|
|
RENOVATED
|
|
USED
|
|
}
|
|
|
|
enum LegalStatus {
|
|
SO_DO
|
|
SO_HONG
|
|
LAND_USE_RIGHT
|
|
JOINT_USE_RIGHT
|
|
AWAITING
|
|
NO_CERTIFICATE
|
|
}
|
|
|
|
model Property {
|
|
id String @id @default(cuid())
|
|
propertyType PropertyType
|
|
title String
|
|
description String @db.Text
|
|
address String
|
|
ward String
|
|
district String
|
|
city String
|
|
/// Lower-cased, unaccented, whitespace-collapsed concatenation of
|
|
/// address/ward/district/city. Used for duplicate detection (TEC-2932).
|
|
/// Nullable until the backfill migration covers historic rows.
|
|
addressNormalized 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 LegalStatus?
|
|
certificateVerified Boolean @default(false)
|
|
amenities Json?
|
|
nearbyPOIs Json?
|
|
metroDistanceM Float?
|
|
projectName String?
|
|
projectDevelopmentId String?
|
|
projectDevelopment ProjectDevelopment? @relation(fields: [projectDevelopmentId], references: [id], onDelete: SetNull)
|
|
furnishing Furnishing?
|
|
propertyCondition PropertyCondition?
|
|
balconyDirection Direction?
|
|
// CHECK ("maintenanceFeeVND" IS NULL OR "maintenanceFeeVND" >= 0)
|
|
maintenanceFeeVND BigInt?
|
|
parkingSlots Int?
|
|
viewType String[] @default([])
|
|
petFriendly Boolean?
|
|
suitableFor String[] @default([])
|
|
whyThisLocation String? @db.Text
|
|
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])
|
|
@@index([addressNormalized])
|
|
// [TEC-3055] Ward-level heatmap & listing-volume drill-down
|
|
@@index([ward, city])
|
|
}
|
|
|
|
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)
|
|
// CHECK ("priceVND" > 0) — see migration 20260420000000_add_price_check_constraints
|
|
priceVND BigInt
|
|
// CHECK ("pricePerM2" IS NULL OR "pricePerM2" > 0)
|
|
pricePerM2 Float?
|
|
// CHECK ("rentPriceMonthly" IS NULL OR "rentPriceMonthly" > 0)
|
|
rentPriceMonthly BigInt?
|
|
commissionPct Float? @default(2.0)
|
|
// CHECK ("aiPriceEstimate" IS NULL OR "aiPriceEstimate" > 0)
|
|
aiPriceEstimate BigInt?
|
|
aiConfidence Float?
|
|
moderationScore Float?
|
|
moderationNotes String?
|
|
viewCount Int @default(0)
|
|
saveCount Int @default(0)
|
|
inquiryCount Int @default(0)
|
|
featuredUntil DateTime?
|
|
featuredPackage String? /// "3_days" | "7_days" | "30_days"
|
|
expiresAt DateTime?
|
|
expiryNotifiedAt DateTime?
|
|
publishedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
transactions Transaction[]
|
|
inquiries Inquiry[]
|
|
orders Order[]
|
|
priceHistories PriceHistory[]
|
|
savedByUsers SavedListing[]
|
|
conversations Conversation[]
|
|
flags ListingFlag[]
|
|
|
|
// --- 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)
|
|
// CHECK ("oldPrice" > 0) — see migration 20260420000000_add_price_check_constraints
|
|
oldPrice BigInt
|
|
// CHECK ("newPrice" > 0)
|
|
newPrice BigInt
|
|
source String @default("manual_update")
|
|
changedAt DateTime @default(now())
|
|
|
|
@@index([listingId, changedAt(sort: Desc)])
|
|
}
|
|
|
|
// =============================================================================
|
|
// LISTING FLAGS (user-submitted abuse/scam reports)
|
|
// =============================================================================
|
|
|
|
enum FlagReason {
|
|
SCAM
|
|
DUPLICATE
|
|
WRONG_INFO
|
|
ALREADY_SOLD
|
|
INAPPROPRIATE
|
|
}
|
|
|
|
enum FlagStatus {
|
|
PENDING
|
|
REVIEWED
|
|
DISMISSED
|
|
}
|
|
|
|
model ListingFlag {
|
|
id String @id @default(cuid())
|
|
listingId String
|
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
|
reporterId String
|
|
reporter User @relation("listingFlagsReported", fields: [reporterId], references: [id], onDelete: Restrict)
|
|
reason FlagReason
|
|
description String? /// Mô tả chi tiết (tuỳ chọn)
|
|
status FlagStatus @default(PENDING)
|
|
reviewedBy String?
|
|
reviewedAt DateTime?
|
|
reviewNotes String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([listingId, reporterId]) // one report per user per listing
|
|
@@index([listingId])
|
|
@@index([status, createdAt(sort: Desc)])
|
|
@@index([reporterId])
|
|
@@map("listing_flags")
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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
|
|
|
|
@@unique([subscriptionId, metric, periodStart, periodEnd])
|
|
@@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
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
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)])
|
|
}
|
|
|
|
// Free-form moderation audit log capturing every approve/reject/edit/flag action
|
|
// performed by moderators on listings, properties, inquiries and other targets.
|
|
// Strings (not enums) are used for `targetType` and `action` so that adding new
|
|
// moderation surfaces does not require an enum migration. Existing AdminAuditLog
|
|
// stays as-is for the admin-action timeline; this table is the moderator-centric
|
|
// view used by TEC-2926.
|
|
model ModerationAuditLog {
|
|
id String @id @default(uuid())
|
|
targetType String
|
|
targetId String
|
|
action String
|
|
moderatorId String
|
|
reason String?
|
|
metadata Json?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([targetType, targetId])
|
|
@@index([moderatorId, createdAt(sort: Desc)])
|
|
@@index([action, createdAt(sort: Desc)])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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 Decimal? @db.Decimal(18, 4)
|
|
rbfRentUsdM2Month Decimal? @db.Decimal(18, 4)
|
|
rbwRentUsdM2Month Decimal? @db.Decimal(18, 4)
|
|
managementFeeUsd Decimal? @db.Decimal(18, 4)
|
|
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)
|
|
/// Optional owning operator user (role=PARK_OPERATOR). NULL for parks not
|
|
/// yet assigned to an operator account — admin still manages those.
|
|
ownerId String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
listings IndustrialListing[]
|
|
owner User? @relation("IndustrialParkOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
|
|
|
@@index([status])
|
|
@@index([province])
|
|
@@index([region])
|
|
@@index([developer])
|
|
@@index([location], type: Gist)
|
|
@@index([isVerified])
|
|
@@index([occupancyRate])
|
|
@@index([landRentUsdM2Year])
|
|
@@index([region, province, status])
|
|
@@index([createdAt])
|
|
@@index([ownerId])
|
|
}
|
|
|
|
model IndustrialListing {
|
|
id String @id @default(cuid())
|
|
parkId String
|
|
park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade)
|
|
agentId String?
|
|
agent Agent? @relation("IndustrialListingAgent", fields: [agentId], references: [id], onDelete: SetNull)
|
|
sellerId String
|
|
seller User @relation("IndustrialListingSeller", fields: [sellerId], references: [id], onDelete: Restrict)
|
|
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 Decimal? @db.Decimal(18, 4)
|
|
pricingUnit String? // "usd/m2/month", "usd/m2/year"
|
|
totalLeasePrice Decimal? @db.Decimal(18, 4)
|
|
managementFee Decimal? @db.Decimal(18, 4)
|
|
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?
|
|
listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull)
|
|
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])
|
|
}
|
|
|
|
// =============================================================================
|
|
// SYSTEM SETTINGS
|
|
// =============================================================================
|
|
// Key/value store for runtime-configurable system settings (e.g. AI provider
|
|
// credentials). Values are persisted as plain strings — TODO: encrypt `isSecret`
|
|
// entries at rest as a future hardening step.
|
|
|
|
model SystemSetting {
|
|
key String @id
|
|
value String @db.Text
|
|
valueType String @default("string") // "string" | "secret" | "number" | "boolean"
|
|
isSecret Boolean @default(false)
|
|
updatedAt DateTime @updatedAt
|
|
updatedBy String?
|
|
}
|
|
|
|
// =============================================================================
|
|
// VIETNAM ADMINISTRATIVE REFERENCE (ĐVHCVN)
|
|
// =============================================================================
|
|
// Authoritative 3-level administrative hierarchy sourced from GSO
|
|
// (danhmuchanhchinhvn.gso.gov.vn): 63 provinces / ~705 districts / ~10.6K wards.
|
|
// Seeded from `prisma/data/vn-admin/` snapshot via `prisma/seed-vn-admin.ts`.
|
|
// [GOO-21]
|
|
|
|
model VnProvince {
|
|
code String @id // GSO province code, zero-padded (e.g. "01", "79")
|
|
name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh"
|
|
nameEn String?
|
|
type String // "Thành phố Trung ương" | "Tỉnh"
|
|
codename String // slug, e.g. "thanh_pho_ho_chi_minh"
|
|
phoneCode Int?
|
|
districts VnDistrict[]
|
|
|
|
@@index([codename])
|
|
@@map("vn_provinces")
|
|
}
|
|
|
|
model VnDistrict {
|
|
code String @id // GSO district code
|
|
provinceCode String
|
|
name String // e.g. "Quận 1", "Huyện Củ Chi", "Thành phố Thủ Đức"
|
|
nameEn String?
|
|
type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh"
|
|
codename String
|
|
province VnProvince @relation(fields: [provinceCode], references: [code], onDelete: Restrict)
|
|
wards VnWard[]
|
|
|
|
@@index([provinceCode])
|
|
@@index([codename])
|
|
@@map("vn_districts")
|
|
}
|
|
|
|
model VnWard {
|
|
code String @id
|
|
districtCode String
|
|
name String
|
|
nameEn String?
|
|
type String // "Phường" | "Xã" | "Thị trấn"
|
|
codename String
|
|
district VnDistrict @relation(fields: [districtCode], references: [code], onDelete: Restrict)
|
|
|
|
@@index([districtCode])
|
|
@@index([codename])
|
|
@@map("vn_wards")
|
|
}
|
|
|
|
/// Historical name/code changes so legacy data (e.g. Quận 2, Quận 9) and post-2025
|
|
/// merges can still resolve to the current district/ward.
|
|
model VnAdministrativeAlias {
|
|
id String @id @default(cuid())
|
|
oldCode String? // GSO code pre-change, when known
|
|
oldName String // human-readable legacy name, e.g. "Quận 2"
|
|
level String // "province" | "district" | "ward"
|
|
newDistrictCode String?
|
|
newWardCode String?
|
|
reason String // e.g. "merged_into_thu_duc_2021", "2025_redistrict"
|
|
mergedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([oldName])
|
|
@@index([newDistrictCode])
|
|
@@index([newWardCode])
|
|
@@map("vn_administrative_aliases")
|
|
}
|