Files
goodgo-platform/prisma/schema.prisma
Ho Ngoc Hai a720825257 feat(notifications): add ZaloOaLinkController + migration + schema — TEC-3065
Include files missed from previous commit:
- ZaloOaLinkController (GET /auth/zalo-oa/link, GET /auth/zalo-oa/callback, DELETE)
- prisma/schema.prisma — ZaloAccountLink model + User.zaloAccountLink relation
- prisma/migrations/20260421010000_add_zalo_account_links/migration.sql
- Updated ZaloOaService, webhook controller, notifications module, and specs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-21 04:49:52 +07:00

1436 lines
42 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?
@@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[]
@@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
}
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
}
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 String?
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?
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)
// 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)])
}
// =============================================================================
// 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)])
}
// 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 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)
/// 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?
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])
}
// =============================================================================
// 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?
}