fix(db): add explicit onDelete strategies to all Prisma FK relations

Audit and update all foreign key relations in schema.prisma with
appropriate cascade/restrict/set-null strategies to prevent orphaned
records and FK constraint violations on parent deletion.

Changes (RESTRICT → CASCADE):
- Agent.userId, Listing.propertyId, Transaction.listingId
- Inquiry.listingId, Inquiry.userId, Lead.agentId
- Subscription.userId, UsageRecord.subscriptionId
- Valuation.propertyId, Review.userId

Confirmed correct (no change needed):
- Listing.agentId (SetNull), Listing.sellerId (Restrict)
- Transaction.buyerId (Restrict), Payment.userId (Restrict)
- Payment.transactionId (SetNull), Subscription.planId (Restrict)
- PropertyMedia, SavedSearch, RefreshToken, OAuthAccount (CASCADE)

Migration: 20260411000000_add_cascade_delete_strategies

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 00:21:46 +07:00
parent b7f9664709
commit 45e48c063c
2 changed files with 176 additions and 79 deletions

View File

@@ -0,0 +1,59 @@
-- DropForeignKey
ALTER TABLE "Agent" DROP CONSTRAINT "Agent_userId_fkey";
-- DropForeignKey
ALTER TABLE "Listing" DROP CONSTRAINT "Listing_propertyId_fkey";
-- DropForeignKey
ALTER TABLE "Transaction" DROP CONSTRAINT "Transaction_listingId_fkey";
-- DropForeignKey
ALTER TABLE "Inquiry" DROP CONSTRAINT "Inquiry_listingId_fkey";
-- DropForeignKey
ALTER TABLE "Inquiry" DROP CONSTRAINT "Inquiry_userId_fkey";
-- DropForeignKey
ALTER TABLE "Lead" DROP CONSTRAINT "Lead_agentId_fkey";
-- DropForeignKey
ALTER TABLE "Subscription" DROP CONSTRAINT "Subscription_userId_fkey";
-- DropForeignKey
ALTER TABLE "UsageRecord" DROP CONSTRAINT "UsageRecord_subscriptionId_fkey";
-- DropForeignKey
ALTER TABLE "Valuation" DROP CONSTRAINT "Valuation_propertyId_fkey";
-- DropForeignKey
ALTER TABLE "Review" DROP CONSTRAINT "Review_userId_fkey";
-- AddForeignKey: Agent.userId -> User.id (CASCADE)
ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Listing.propertyId -> Property.id (CASCADE)
ALTER TABLE "Listing" ADD CONSTRAINT "Listing_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Transaction.listingId -> Listing.id (CASCADE)
ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Inquiry.listingId -> Listing.id (CASCADE)
ALTER TABLE "Inquiry" ADD CONSTRAINT "Inquiry_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Inquiry.userId -> User.id (CASCADE)
ALTER TABLE "Inquiry" ADD CONSTRAINT "Inquiry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Lead.agentId -> Agent.id (CASCADE)
ALTER TABLE "Lead" ADD CONSTRAINT "Lead_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Subscription.userId -> User.id (CASCADE)
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: UsageRecord.subscriptionId -> Subscription.id (CASCADE)
ALTER TABLE "UsageRecord" ADD CONSTRAINT "UsageRecord_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Valuation.propertyId -> Property.id (CASCADE)
ALTER TABLE "Valuation" ADD CONSTRAINT "Valuation_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey: Review.userId -> User.id (CASCADE)
ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -32,31 +32,31 @@ enum KYCStatus {
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
email String? @unique email String? @unique
phone String @unique phone String @unique
passwordHash String? passwordHash String?
fullName String fullName String
avatarUrl String? avatarUrl String?
role UserRole @default(BUYER) role UserRole @default(BUYER)
kycStatus KYCStatus @default(NONE) kycStatus KYCStatus @default(NONE)
kycData Json? kycData Json?
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime? deletedAt DateTime?
deletionScheduledAt DateTime? deletionScheduledAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
agent Agent? agent Agent?
listings Listing[] listings Listing[]
savedSearches SavedSearch[] savedSearches SavedSearch[]
subscription Subscription? subscription Subscription?
payments Payment[] payments Payment[]
reviews Review[] reviews Review[]
inquiriesSent Inquiry[] inquiriesSent Inquiry[]
refreshTokens RefreshToken[] refreshTokens RefreshToken[]
oauthAccounts OAuthAccount[] oauthAccounts OAuthAccount[]
buyerTransactions Transaction[] @relation("BuyerTransactions") buyerTransactions Transaction[] @relation("BuyerTransactions")
@@index([role]) @@index([role])
@@index([kycStatus]) @@index([kycStatus])
@@ -64,7 +64,6 @@ model User {
@@index([deletedAt]) @@index([deletedAt])
@@index([deletionScheduledAt]) @@index([deletionScheduledAt])
@@index([createdAt]) @@index([createdAt])
// --- Compound indexes (query optimization) --- // --- Compound indexes (query optimization) ---
@@index([role, isActive, createdAt(sort: Desc)]) @@index([role, isActive, createdAt(sort: Desc)])
@@index([kycStatus, createdAt]) @@index([kycStatus, createdAt])
@@ -76,14 +75,14 @@ enum OAuthProvider {
} }
model RefreshToken { model RefreshToken {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
token String @unique token String @unique
family String family String
expiresAt DateTime expiresAt DateTime
revokedAt DateTime? revokedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
@@index([family]) @@index([family])
@@ -110,14 +109,14 @@ model OAuthAccount {
model Agent { model Agent {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
licenseNumber String? licenseNumber String?
agency String? agency String?
qualityScore Float @default(0) qualityScore Float @default(0)
totalDeals Int @default(0) totalDeals Int @default(0)
responseTimeAvg Int? responseTimeAvg Int?
bio String? bio String?
serviceAreas Json // ["quan-1", "quan-7", "thu-duc"] serviceAreas Json // ["quan-1", "quan-7", "thu-duc"]
isVerified Boolean @default(false) isVerified Boolean @default(false)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -170,10 +169,10 @@ enum Direction {
} }
model Property { model Property {
id String @id @default(cuid()) id String @id @default(cuid())
propertyType PropertyType propertyType PropertyType
title String title String
description String @db.Text description String @db.Text
address String address String
ward String ward String
district String district String
@@ -193,8 +192,8 @@ model Property {
nearbyPOIs Json? nearbyPOIs Json?
metroDistanceM Float? metroDistanceM Float?
projectName String? projectName String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
listings Listing[] listings Listing[]
valuations Valuation[] valuations Valuation[]
@@ -204,7 +203,6 @@ model Property {
@@index([propertyType]) @@index([propertyType])
@@index([district, city]) @@index([district, city])
@@index([location], type: Gist) @@index([location], type: Gist)
// --- Compound indexes (query optimization) --- // --- Compound indexes (query optimization) ---
@@index([district, propertyType]) @@index([district, propertyType])
@@index([district, city, propertyType]) @@index([district, city, propertyType])
@@ -215,7 +213,7 @@ model PropertyMedia {
propertyId String propertyId String
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
url String url String
type String // "image" | "video" type String // "image" | "video"
order Int @default(0) order Int @default(0)
caption String? caption String?
aiTags Json? aiTags Json?
@@ -227,11 +225,11 @@ model PropertyMedia {
model Listing { model Listing {
id String @id @default(cuid()) id String @id @default(cuid())
propertyId String propertyId String
property Property @relation(fields: [propertyId], references: [id]) property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
agentId String? agentId String?
agent Agent? @relation(fields: [agentId], references: [id]) agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull)
sellerId String sellerId String
seller User @relation(fields: [sellerId], references: [id]) seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict)
transactionType TransactionType transactionType TransactionType
status ListingStatus @default(DRAFT) status ListingStatus @default(DRAFT)
priceVND BigInt priceVND BigInt
@@ -265,7 +263,6 @@ model Listing {
@@index([createdAt]) @@index([createdAt])
@@index([featuredUntil]) @@index([featuredUntil])
@@index([expiresAt]) @@index([expiresAt])
// --- Compound indexes (query optimization) --- // --- Compound indexes (query optimization) ---
@@index([sellerId, status, publishedAt(sort: Desc)]) @@index([sellerId, status, publishedAt(sort: Desc)])
@@index([agentId, status]) @@index([agentId, status])
@@ -309,9 +306,9 @@ enum TransactionStatus {
model Transaction { model Transaction {
id String @id @default(cuid()) id String @id @default(cuid())
listingId String listingId String
listing Listing @relation(fields: [listingId], references: [id]) listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
buyerId String buyerId String
buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id]) buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id], onDelete: Restrict)
status TransactionStatus @default(INQUIRY) status TransactionStatus @default(INQUIRY)
agreedPrice BigInt? agreedPrice BigInt?
depositAmount BigInt? depositAmount BigInt?
@@ -330,9 +327,9 @@ model Transaction {
model Inquiry { model Inquiry {
id String @id @default(cuid()) id String @id @default(cuid())
listingId String listingId String
listing Listing @relation(fields: [listingId], references: [id]) listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade)
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
message String @db.Text message String @db.Text
phone String? phone String?
isRead Boolean @default(false) isRead Boolean @default(false)
@@ -355,7 +352,7 @@ enum LeadStatus {
model Lead { model Lead {
id String @id @default(cuid()) id String @id @default(cuid())
agentId String agentId String
agent Agent @relation(fields: [agentId], references: [id]) agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
name String name String
phone String phone String
email String? email String?
@@ -397,20 +394,20 @@ enum PaymentType {
} }
model Payment { model Payment {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Restrict)
transactionId String? transactionId String?
transaction Transaction? @relation(fields: [transactionId], references: [id]) transaction Transaction? @relation(fields: [transactionId], references: [id], onDelete: SetNull)
provider PaymentProvider provider PaymentProvider
type PaymentType type PaymentType
amountVND BigInt amountVND BigInt
status PaymentStatus @default(PENDING) status PaymentStatus @default(PENDING)
providerTxId String? providerTxId String?
callbackData Json? callbackData Json?
idempotencyKey String? idempotencyKey String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@unique([userId, provider, idempotencyKey], name: "Payment_idempotency_unique") @@unique([userId, provider, idempotencyKey], name: "Payment_idempotency_unique")
@@index([userId]) @@index([userId])
@@ -418,7 +415,6 @@ model Payment {
@@index([status]) @@index([status])
@@index([providerTxId]) @@index([providerTxId])
@@index([createdAt]) @@index([createdAt])
// --- Compound indexes (query optimization) --- // --- Compound indexes (query optimization) ---
@@index([userId, status, createdAt(sort: Desc)]) @@index([userId, status, createdAt(sort: Desc)])
@@index([userId, type, createdAt(sort: Desc)]) @@index([userId, type, createdAt(sort: Desc)])
@@ -443,17 +439,17 @@ enum SubscriptionStatus {
} }
model Plan { model Plan {
id String @id @default(cuid()) id String @id @default(cuid())
tier PlanTier @unique tier PlanTier @unique
name String name String
priceMonthlyVND BigInt priceMonthlyVND BigInt
priceYearlyVND BigInt priceYearlyVND BigInt
maxListings Int? maxListings Int?
maxSavedSearches Int? maxSavedSearches Int?
maxAnalyticsQueries Int? maxAnalyticsQueries Int?
maxMediaUploads Int? maxMediaUploads Int?
features Json features Json
isActive Boolean @default(true) isActive Boolean @default(true)
subscriptions Subscription[] subscriptions Subscription[]
} }
@@ -461,9 +457,9 @@ model Plan {
model Subscription { model Subscription {
id String @id @default(cuid()) id String @id @default(cuid())
userId String @unique userId String @unique
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
planId String planId String
plan Plan @relation(fields: [planId], references: [id]) plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict)
status SubscriptionStatus @default(ACTIVE) status SubscriptionStatus @default(ACTIVE)
currentPeriodStart DateTime currentPeriodStart DateTime
currentPeriodEnd DateTime currentPeriodEnd DateTime
@@ -480,7 +476,7 @@ model Subscription {
model UsageRecord { model UsageRecord {
id String @id @default(cuid()) id String @id @default(cuid())
subscriptionId String subscriptionId String
subscription Subscription @relation(fields: [subscriptionId], references: [id]) subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
metric String metric String
count Int count Int
periodStart DateTime periodStart DateTime
@@ -496,7 +492,7 @@ model UsageRecord {
model Valuation { model Valuation {
id String @id @default(cuid()) id String @id @default(cuid())
propertyId String propertyId String
property Property @relation(fields: [propertyId], references: [id]) property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
estimatedPrice BigInt estimatedPrice BigInt
confidence Float confidence Float
pricePerM2 Float pricePerM2 Float
@@ -547,18 +543,18 @@ enum NotificationStatus {
} }
model NotificationLog { model NotificationLog {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
channel NotificationChannel channel NotificationChannel
templateKey String templateKey String
subject String? subject String?
body String @db.Text body String @db.Text
metadata Json? metadata Json?
status NotificationStatus @default(PENDING) status NotificationStatus @default(PENDING)
errorDetail String? errorDetail String?
sentAt DateTime? sentAt DateTime?
readAt DateTime? readAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([userId]) @@index([userId])
@@index([channel, status]) @@index([channel, status])
@@ -581,6 +577,48 @@ model NotificationPreference {
@@index([userId]) @@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 // REVIEWS
// ============================================================================= // =============================================================================
@@ -588,7 +626,7 @@ model NotificationPreference {
model Review { model Review {
id String @id @default(cuid()) id String @id @default(cuid())
userId String userId String
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id], onDelete: Cascade)
targetType String targetType String
targetId String targetId String
rating Int rating Int