The Review table was missing an index on userId, causing full table scans when querying reviews by user. All other FK columns across 22 models were verified to have proper indexes already (including Listing.sellerId which was added in a prior migration). Co-Authored-By: Paperclip <noreply@paperclip.ing>
572 lines
14 KiB
Plaintext
572 lines
14 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? @unique
|
|
phone String @unique
|
|
passwordHash String?
|
|
fullName String
|
|
avatarUrl String?
|
|
role UserRole @default(BUYER)
|
|
kycStatus KYCStatus @default(NONE)
|
|
kycData Json?
|
|
isActive Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
agent Agent?
|
|
listings Listing[]
|
|
savedSearches SavedSearch[]
|
|
subscription Subscription?
|
|
payments Payment[]
|
|
reviews Review[]
|
|
inquiriesSent Inquiry[]
|
|
refreshTokens RefreshToken[]
|
|
oauthAccounts OAuthAccount[]
|
|
buyerTransactions Transaction[] @relation("BuyerTransactions")
|
|
|
|
@@index([phone])
|
|
@@index([role])
|
|
@@index([kycStatus])
|
|
@@index([isActive])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
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])
|
|
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])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
listings Listing[]
|
|
valuations Valuation[]
|
|
media PropertyMedia[]
|
|
|
|
@@index([propertyType])
|
|
@@index([district, city])
|
|
@@index([location], type: Gist)
|
|
}
|
|
|
|
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])
|
|
agentId String?
|
|
agent Agent? @relation(fields: [agentId], references: [id])
|
|
sellerId String
|
|
seller User @relation(fields: [sellerId], references: [id])
|
|
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[]
|
|
|
|
@@index([status])
|
|
@@index([transactionType])
|
|
@@index([priceVND])
|
|
@@index([sellerId])
|
|
@@index([propertyId])
|
|
@@index([agentId])
|
|
@@index([publishedAt])
|
|
@@index([createdAt])
|
|
@@index([featuredUntil])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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])
|
|
buyerId String
|
|
buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id])
|
|
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])
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
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])
|
|
name String
|
|
phone String
|
|
email String?
|
|
source String
|
|
score Float?
|
|
notes Json?
|
|
status LeadStatus @default(NEW)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([agentId])
|
|
@@index([status])
|
|
}
|
|
|
|
// =============================================================================
|
|
// PAYMENTS
|
|
// =============================================================================
|
|
|
|
enum PaymentProvider {
|
|
VNPAY
|
|
MOMO
|
|
ZALOPAY
|
|
BANK_TRANSFER
|
|
}
|
|
|
|
enum PaymentStatus {
|
|
PENDING
|
|
PROCESSING
|
|
COMPLETED
|
|
FAILED
|
|
REFUNDED
|
|
}
|
|
|
|
enum PaymentType {
|
|
SUBSCRIPTION
|
|
LISTING_FEE
|
|
DEPOSIT
|
|
FEATURED_LISTING
|
|
}
|
|
|
|
model Payment {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
transactionId String?
|
|
transaction Transaction? @relation(fields: [transactionId], references: [id])
|
|
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([status])
|
|
@@index([providerTxId])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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?
|
|
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])
|
|
planId String
|
|
plan Plan @relation(fields: [planId], references: [id])
|
|
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])
|
|
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])
|
|
estimatedPrice BigInt
|
|
confidence Float
|
|
pricePerM2 Float
|
|
comparables Json
|
|
features Json
|
|
modelVersion String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([propertyId])
|
|
}
|
|
|
|
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?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([channel, status])
|
|
@@index([templateKey])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
// =============================================================================
|
|
// REVIEWS
|
|
// =============================================================================
|
|
|
|
model Review {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id])
|
|
targetType String
|
|
targetId String
|
|
rating Int
|
|
comment String? @db.Text
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([targetType, targetId])
|
|
@@index([userId])
|
|
}
|