// ============================================================================= // 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)]) }