- Add Order entity with lifecycle (pending → paid → completed/cancelled/refunded) - Add Escrow entity with hold/release/dispute flow for secure transactions - Add PlatformFee value object with tiered commission calculation - Implement CQRS: CreateOrder, CancelOrder, HoldEscrow, ReleaseEscrow commands - Add GetOrderStatus query handler - Add OrdersController with REST endpoints and DTOs - Add Prisma models for Order, Escrow, EscrowStatusHistory - Add domain event classes for order and escrow state changes - Add unit tests for Order, Escrow entities and PlatformFee VO - Update PROJECT_TRACKER to Wave 14 status Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
746 lines
19 KiB
Plaintext
746 lines
19 KiB
Plaintext
// =============================================================================
|
|
// GoodGo Platform — Prisma Schema
|
|
// PostgreSQL 16 + PostGIS
|
|
// =============================================================================
|
|
|
|
generator client {
|
|
provider = "prisma-client-js"
|
|
previewFeatures = ["postgresqlExtensions"]
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
extensions = [postgis]
|
|
}
|
|
|
|
// =============================================================================
|
|
// AUTH
|
|
// =============================================================================
|
|
|
|
enum UserRole {
|
|
BUYER
|
|
SELLER
|
|
AGENT
|
|
ADMIN
|
|
}
|
|
|
|
enum KYCStatus {
|
|
NONE
|
|
PENDING
|
|
VERIFIED
|
|
REJECTED
|
|
}
|
|
|
|
model User {
|
|
id String @id @default(cuid())
|
|
email String?
|
|
emailHash String? @unique
|
|
phone String
|
|
phoneHash String? @unique
|
|
passwordHash String?
|
|
fullName String
|
|
avatarUrl String?
|
|
role UserRole @default(BUYER)
|
|
kycStatus KYCStatus @default(NONE)
|
|
kycData Json?
|
|
isActive Boolean @default(true)
|
|
deletedAt DateTime?
|
|
deletionScheduledAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
// MFA fields
|
|
totpSecret String? // Encrypted TOTP secret
|
|
totpEnabled Boolean @default(false)
|
|
totpBackupCodes String[] // Bcrypt-hashed backup codes
|
|
totpEnabledAt DateTime?
|
|
|
|
agent Agent?
|
|
listings Listing[]
|
|
savedSearches SavedSearch[]
|
|
subscription Subscription?
|
|
payments Payment[]
|
|
reviews Review[]
|
|
inquiriesSent Inquiry[]
|
|
refreshTokens RefreshToken[]
|
|
oauthAccounts OAuthAccount[]
|
|
buyerTransactions Transaction[] @relation("BuyerTransactions")
|
|
buyerOrders Order[] @relation("BuyerOrders")
|
|
sellerOrders Order[] @relation("SellerOrders")
|
|
mfaChallenges MfaChallenge[]
|
|
|
|
@@index([role])
|
|
@@index([kycStatus])
|
|
@@index([isActive])
|
|
@@index([deletedAt])
|
|
@@index([deletionScheduledAt])
|
|
@@index([createdAt])
|
|
// --- Compound indexes (query optimization) ---
|
|
@@index([role, isActive, createdAt(sort: Desc)])
|
|
@@index([kycStatus, createdAt])
|
|
}
|
|
|
|
model MfaChallenge {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
type String // "totp" | "backup_code"
|
|
attemptCount Int @default(0)
|
|
maxAttempts Int @default(5)
|
|
isVerified Boolean @default(false)
|
|
expiresAt DateTime
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId, expiresAt])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
enum OAuthProvider {
|
|
GOOGLE
|
|
ZALO
|
|
}
|
|
|
|
model RefreshToken {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
token String @unique
|
|
family String
|
|
expiresAt DateTime
|
|
revokedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([family])
|
|
@@index([expiresAt])
|
|
}
|
|
|
|
model OAuthAccount {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
provider OAuthProvider
|
|
providerUserId String
|
|
accessToken String?
|
|
refreshToken String?
|
|
expiresAt DateTime?
|
|
profile Json?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([provider, providerUserId])
|
|
@@index([userId])
|
|
}
|
|
|
|
model Agent {
|
|
id String @id @default(cuid())
|
|
userId String @unique
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
licenseNumber String?
|
|
agency String?
|
|
qualityScore Float @default(0)
|
|
totalDeals Int @default(0)
|
|
responseTimeAvg Int?
|
|
bio String?
|
|
serviceAreas Json // ["quan-1", "quan-7", "thu-duc"]
|
|
isVerified Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
listings Listing[]
|
|
leads Lead[]
|
|
|
|
@@index([qualityScore])
|
|
@@index([isVerified])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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[]
|
|
|
|
// --- Single-column indexes ---
|
|
@@index([propertyType])
|
|
@@index([district, city])
|
|
@@index([location], type: Gist)
|
|
// --- Compound indexes (query optimization) ---
|
|
@@index([district, propertyType])
|
|
@@index([district, city, propertyType])
|
|
}
|
|
|
|
model PropertyMedia {
|
|
id String @id @default(cuid())
|
|
propertyId String
|
|
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
|
url String
|
|
type String // "image" | "video"
|
|
order Int @default(0)
|
|
caption String?
|
|
aiTags Json?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([propertyId])
|
|
}
|
|
|
|
model Listing {
|
|
id String @id @default(cuid())
|
|
propertyId String
|
|
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
|
agentId String?
|
|
agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
|
|
sellerId String
|
|
seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
|
|
transactionType TransactionType
|
|
status ListingStatus @default(DRAFT)
|
|
priceVND BigInt
|
|
pricePerM2 Float?
|
|
rentPriceMonthly BigInt?
|
|
commissionPct Float? @default(2.0)
|
|
aiPriceEstimate BigInt?
|
|
aiConfidence Float?
|
|
moderationScore Float?
|
|
moderationNotes String?
|
|
viewCount Int @default(0)
|
|
saveCount Int @default(0)
|
|
inquiryCount Int @default(0)
|
|
featuredUntil DateTime?
|
|
expiresAt DateTime?
|
|
publishedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
transactions Transaction[]
|
|
inquiries Inquiry[]
|
|
orders Order[]
|
|
|
|
// --- 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])
|
|
}
|
|
|
|
// =============================================================================
|
|
// SEARCH
|
|
// =============================================================================
|
|
|
|
model SavedSearch {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
name String
|
|
filters Json
|
|
alertEnabled Boolean @default(true)
|
|
lastAlertAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
}
|
|
|
|
// =============================================================================
|
|
// TRANSACTIONS
|
|
// =============================================================================
|
|
|
|
enum TransactionStatus {
|
|
INQUIRY
|
|
VIEWING_SCHEDULED
|
|
OFFER_MADE
|
|
DEPOSIT_PAID
|
|
CONTRACT_SIGNING
|
|
COMPLETED
|
|
CANCELLED
|
|
}
|
|
|
|
model Transaction {
|
|
id String @id @default(cuid())
|
|
listingId String
|
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
|
buyerId String
|
|
buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id], onDelete: Restrict)
|
|
status TransactionStatus @default(INQUIRY)
|
|
agreedPrice BigInt?
|
|
depositAmount BigInt?
|
|
timeline Json?
|
|
contractUrl String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
payments Payment[]
|
|
|
|
@@index([listingId])
|
|
@@index([buyerId])
|
|
@@index([status])
|
|
}
|
|
|
|
model Inquiry {
|
|
id String @id @default(cuid())
|
|
listingId String
|
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
message String @db.Text
|
|
phone String?
|
|
isRead Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([listingId])
|
|
@@index([userId])
|
|
@@index([listingId, userId])
|
|
}
|
|
|
|
enum LeadStatus {
|
|
NEW
|
|
CONTACTED
|
|
QUALIFIED
|
|
NEGOTIATING
|
|
CONVERTED
|
|
LOST
|
|
}
|
|
|
|
model Lead {
|
|
id String @id @default(cuid())
|
|
agentId String
|
|
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
|
name String
|
|
phone String
|
|
phoneHash String?
|
|
email String?
|
|
emailHash String?
|
|
source String
|
|
score Float?
|
|
notes Json?
|
|
status LeadStatus @default(NEW)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([agentId])
|
|
@@index([status])
|
|
@@index([phoneHash])
|
|
@@index([emailHash])
|
|
}
|
|
|
|
// =============================================================================
|
|
// PAYMENTS
|
|
// =============================================================================
|
|
|
|
enum PaymentProvider {
|
|
VNPAY
|
|
MOMO
|
|
ZALOPAY
|
|
BANK_TRANSFER
|
|
}
|
|
|
|
enum PaymentStatus {
|
|
PENDING
|
|
PROCESSING
|
|
COMPLETED
|
|
FAILED
|
|
REFUNDED
|
|
}
|
|
|
|
enum PaymentType {
|
|
SUBSCRIPTION
|
|
LISTING_FEE
|
|
DEPOSIT
|
|
FEATURED_LISTING
|
|
AUCTION_PAYMENT
|
|
}
|
|
|
|
model Payment {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
user User @relation(fields: [userId], references: [id], onDelete: Restrict)
|
|
transactionId String?
|
|
transaction Transaction? @relation(fields: [transactionId], references: [id], onDelete: SetNull)
|
|
orderId String?
|
|
order Order? @relation(fields: [orderId], references: [id], onDelete: SetNull)
|
|
provider PaymentProvider
|
|
type PaymentType
|
|
amountVND BigInt
|
|
status PaymentStatus @default(PENDING)
|
|
providerTxId String?
|
|
callbackData Json?
|
|
idempotencyKey String?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([userId, provider, idempotencyKey], name: "Payment_idempotency_unique")
|
|
@@index([userId])
|
|
@@index([transactionId])
|
|
@@index([orderId])
|
|
@@index([status])
|
|
@@index([providerTxId])
|
|
@@index([createdAt])
|
|
// --- Compound indexes (query optimization) ---
|
|
@@index([userId, status, createdAt(sort: Desc)])
|
|
@@index([userId, type, createdAt(sort: Desc)])
|
|
}
|
|
|
|
// =============================================================================
|
|
// ORDERS & ESCROW (Auction Settlement)
|
|
// =============================================================================
|
|
|
|
enum OrderStatus {
|
|
CREATED
|
|
PAYMENT_PENDING
|
|
PAYMENT_CONFIRMED
|
|
ESCROW_HELD
|
|
SHIPPED
|
|
DELIVERED
|
|
DISPUTE
|
|
ESCROW_RELEASED
|
|
COMPLETED
|
|
CANCELLED
|
|
REFUNDED
|
|
}
|
|
|
|
enum EscrowStatus {
|
|
PENDING
|
|
HELD
|
|
RELEASED
|
|
REFUNDED
|
|
DISPUTED
|
|
}
|
|
|
|
model Order {
|
|
id String @id @default(cuid())
|
|
buyerId String
|
|
buyer User @relation("BuyerOrders", fields: [buyerId], references: [id], onDelete: Restrict)
|
|
sellerId String
|
|
seller User @relation("SellerOrders", fields: [sellerId], references: [id], onDelete: Restrict)
|
|
listingId String
|
|
listing Listing @relation(fields: [listingId], references: [id], onDelete: Restrict)
|
|
status OrderStatus @default(CREATED)
|
|
amountVND BigInt
|
|
platformFeeVND BigInt
|
|
sellerPayoutVND BigInt
|
|
idempotencyKey String? @unique
|
|
metadata Json?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
payments Payment[]
|
|
escrow Escrow?
|
|
|
|
@@index([buyerId])
|
|
@@index([sellerId])
|
|
@@index([listingId])
|
|
@@index([status])
|
|
@@index([createdAt(sort: Desc)])
|
|
}
|
|
|
|
model Escrow {
|
|
id String @id @default(cuid())
|
|
orderId String @unique
|
|
order Order @relation(fields: [orderId], references: [id], onDelete: Restrict)
|
|
amountVND BigInt
|
|
feeVND BigInt
|
|
status EscrowStatus @default(PENDING)
|
|
heldAt DateTime?
|
|
releasedAt DateTime?
|
|
disputeReason String? @db.Text
|
|
disputedAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@index([status])
|
|
@@index([orderId])
|
|
}
|
|
|
|
// =============================================================================
|
|
// SUBSCRIPTIONS
|
|
// =============================================================================
|
|
|
|
enum PlanTier {
|
|
FREE
|
|
AGENT_PRO
|
|
INVESTOR
|
|
ENTERPRISE
|
|
}
|
|
|
|
enum SubscriptionStatus {
|
|
ACTIVE
|
|
PAST_DUE
|
|
CANCELLED
|
|
EXPIRED
|
|
}
|
|
|
|
model Plan {
|
|
id String @id @default(cuid())
|
|
tier PlanTier @unique
|
|
name String
|
|
priceMonthlyVND BigInt
|
|
priceYearlyVND BigInt
|
|
maxListings Int?
|
|
maxSavedSearches Int?
|
|
maxAnalyticsQueries Int?
|
|
maxMediaUploads Int?
|
|
features Json
|
|
isActive Boolean @default(true)
|
|
|
|
subscriptions Subscription[]
|
|
}
|
|
|
|
model Subscription {
|
|
id String @id @default(cuid())
|
|
userId String @unique
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
planId String
|
|
plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict)
|
|
status SubscriptionStatus @default(ACTIVE)
|
|
currentPeriodStart DateTime
|
|
currentPeriodEnd DateTime
|
|
cancelledAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
usageRecords UsageRecord[]
|
|
|
|
@@index([planId])
|
|
@@index([status])
|
|
}
|
|
|
|
model UsageRecord {
|
|
id String @id @default(cuid())
|
|
subscriptionId String
|
|
subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
|
|
metric String
|
|
count Int
|
|
periodStart DateTime
|
|
periodEnd DateTime
|
|
|
|
@@index([subscriptionId, metric])
|
|
}
|
|
|
|
// =============================================================================
|
|
// ANALYTICS
|
|
// =============================================================================
|
|
|
|
model Valuation {
|
|
id String @id @default(cuid())
|
|
propertyId String
|
|
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
|
|
estimatedPrice BigInt
|
|
confidence Float
|
|
pricePerM2 Float
|
|
comparables Json
|
|
features Json
|
|
modelVersion String
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([propertyId])
|
|
@@index([propertyId, createdAt(sort: Desc)])
|
|
}
|
|
|
|
model MarketIndex {
|
|
id String @id @default(cuid())
|
|
district String
|
|
city String
|
|
propertyType PropertyType
|
|
period String
|
|
medianPrice BigInt
|
|
avgPriceM2 Float
|
|
totalListings Int
|
|
daysOnMarket Float
|
|
inventoryLevel Int
|
|
absorptionRate Float?
|
|
yoyChange Float?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@unique([district, city, propertyType, period])
|
|
@@index([city, period])
|
|
}
|
|
|
|
// =============================================================================
|
|
// NOTIFICATIONS
|
|
// =============================================================================
|
|
|
|
enum NotificationChannel {
|
|
EMAIL
|
|
SMS
|
|
PUSH
|
|
ZALO_OA
|
|
}
|
|
|
|
enum NotificationStatus {
|
|
PENDING
|
|
SENT
|
|
FAILED
|
|
DELIVERED
|
|
}
|
|
|
|
model NotificationLog {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
channel NotificationChannel
|
|
templateKey String
|
|
subject String?
|
|
body String @db.Text
|
|
metadata Json?
|
|
status NotificationStatus @default(PENDING)
|
|
errorDetail String?
|
|
sentAt DateTime?
|
|
readAt DateTime?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([userId])
|
|
@@index([channel, status])
|
|
@@index([templateKey])
|
|
@@index([createdAt])
|
|
@@index([userId, readAt])
|
|
@@index([userId, createdAt(sort: Desc)])
|
|
}
|
|
|
|
model NotificationPreference {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
channel NotificationChannel
|
|
eventType String
|
|
enabled Boolean @default(true)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
@@unique([userId, channel, eventType])
|
|
@@index([userId])
|
|
}
|
|
|
|
// =============================================================================
|
|
// ADMIN AUDIT LOG
|
|
// =============================================================================
|
|
|
|
enum AdminAction {
|
|
LISTING_APPROVED
|
|
LISTING_REJECTED
|
|
LISTING_BULK_APPROVED
|
|
LISTING_BULK_REJECTED
|
|
USER_BANNED
|
|
USER_UNBANNED
|
|
USER_STATUS_UPDATED
|
|
KYC_APPROVED
|
|
KYC_REJECTED
|
|
SUBSCRIPTION_ADJUSTED
|
|
}
|
|
|
|
enum AuditTargetType {
|
|
USER
|
|
LISTING
|
|
SUBSCRIPTION
|
|
}
|
|
|
|
model AdminAuditLog {
|
|
id String @id @default(cuid())
|
|
action AdminAction
|
|
actorId String
|
|
targetId String
|
|
targetType AuditTargetType
|
|
metadata Json?
|
|
ipAddress String?
|
|
userAgent String?
|
|
createdAt DateTime @default(now())
|
|
|
|
@@index([actorId])
|
|
@@index([targetId, targetType])
|
|
@@index([action])
|
|
@@index([createdAt])
|
|
@@index([actorId, createdAt(sort: Desc)])
|
|
@@index([action, createdAt(sort: Desc)])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)])
|
|
}
|