Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 12s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 53s
Deploy / Build API Image (push) Failing after 22s
Deploy / Build Web Image (push) Failing after 14s
Deploy / Build AI Services Image (push) Failing after 12s
E2E Tests / Playwright E2E (push) Failing after 9s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 2s
Security Scanning / Trivy Scan — API Image (push) Failing after 50s
Security Scanning / Trivy Scan — Web Image (push) Failing after 38s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 36s
Security Scanning / Trivy Filesystem Scan (push) Failing after 33s
Security Scanning / Security Gate (push) Failing after 1s
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Closes four gaps the Swagger audit flagged as blocking a full MVP demo,
plus a general documentation pass.
P0 — Forgot/Reset password (auth)
- POST /auth/forgot-password (anti-enumeration: always 200)
- POST /auth/reset-password
- Reuses the Redis-OTP pattern from email/phone change; new key prefix
auth:password_reset_otp with 15-min TTL.
- Emits PasswordResetRequestedEvent; new listener in notifications
dispatches the existing password.reset email template (otp +
expiryMinutes variables already in template.service.ts).
- UserEntity gains changePassword(HashedPassword) domain method; reset
also revokes all refresh tokens for the user.
P0 — Favorites module
- New SavedListing Prisma model (unique(userId, listingId)) with User
and Listing back-relations; schema pushed via db push since the
remote DB was out of sync with migration history.
- New apps/api/src/modules/favorites/ module following the reviews
module's shape (DDD/CQRS: domain repo + Prisma impl + 2 commands
+ 2 queries + controller).
- POST /favorites/:listingId, DELETE /favorites/:listingId,
GET /favorites (paginated), GET /favorites/:listingId/check. All
guarded by JwtAuthGuard.
- FavoritesModule wired into AppModule.
P1 — Resend OTP (auth)
- POST /auth/resend-otp for EMAIL_CHANGE | PHONE_CHANGE. Reads the
pending OTP payload out of Redis and re-emits the original event
without minting a new code, so TTL semantics stay intact. Password
reset resend is done by re-POSTing /auth/forgot-password and is
deliberately not in this enum.
P1 — Agent self-upgrade (agents)
- POST /agents/me/upgrade lets a BUYER/SELLER convert to AGENT. Creates
an Agent row (isVerified=false) and flips User.role in one
$transaction. Rejects if already AGENT/ADMIN or if an Agent row
already exists.
P2 — Swagger enrichment
- @ApiConsumes('multipart/form-data') + body schema on listings media
upload.
- GET /subscriptions/quota/:metric now enumerates the real metric
values from METRIC_TO_PLAN_FIELD.
- POST /avm/batch and /analytics/valuation/batch document the max=50
batch size from their DTO's @ArrayMaxSize.
- GET /admin/dashboard gains a realistic response example schema.
- Admin-gated endpoints in projects/transfer/industrial gain concrete
400/401/403/404 responses.
Swagger endpoint count: 170 → 178. Typecheck clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1315 lines
37 KiB
Plaintext
1315 lines
37 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[]
|
|
transferListings TransferListing[]
|
|
reports Report[]
|
|
savedListings SavedListing[]
|
|
|
|
@@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])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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[]
|
|
isVerified Boolean @default(false)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
properties Property[]
|
|
|
|
@@index([status])
|
|
@@index([district, city])
|
|
@@index([developer])
|
|
@@index([location], type: Gist)
|
|
@@index([isVerified])
|
|
@@index([createdAt])
|
|
@@index([district, city, status])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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?
|
|
projectDevelopmentId String?
|
|
projectDevelopment ProjectDevelopment? @relation(fields: [projectDevelopmentId], references: [id], onDelete: SetNull)
|
|
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])
|
|
}
|
|
|
|
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[]
|
|
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)
|
|
oldPrice BigInt
|
|
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)])
|
|
}
|
|
|
|
// =============================================================================
|
|
// 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)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
listings IndustrialListing[]
|
|
|
|
@@index([status])
|
|
@@index([province])
|
|
@@index([region])
|
|
@@index([developer])
|
|
@@index([location], type: Gist)
|
|
@@index([isVerified])
|
|
@@index([occupancyRate])
|
|
@@index([landRentUsdM2Year])
|
|
@@index([region, province, status])
|
|
@@index([createdAt])
|
|
}
|
|
|
|
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])
|
|
}
|