From 45e48c063ccba95f026ac3abe36921602484883f Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 11 Apr 2026 00:21:46 +0700 Subject: [PATCH] fix(db): add explicit onDelete strategies to all Prisma FK relations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../migration.sql | 59 ++++++ prisma/schema.prisma | 196 +++++++++++------- 2 files changed, 176 insertions(+), 79 deletions(-) create mode 100644 prisma/migrations/20260411000000_add_cascade_delete_strategies/migration.sql diff --git a/prisma/migrations/20260411000000_add_cascade_delete_strategies/migration.sql b/prisma/migrations/20260411000000_add_cascade_delete_strategies/migration.sql new file mode 100644 index 0000000..5b1021e --- /dev/null +++ b/prisma/migrations/20260411000000_add_cascade_delete_strategies/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dfe4736..48b21de 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,31 +32,31 @@ enum KYCStatus { } model User { - id String @id @default(cuid()) - email String? @unique - phone String @unique - passwordHash String? - fullName String - avatarUrl String? - role UserRole @default(BUYER) - kycStatus KYCStatus @default(NONE) - kycData Json? - isActive Boolean @default(true) - deletedAt DateTime? - deletionScheduledAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + email String? @unique + phone String @unique + passwordHash String? + fullName String + avatarUrl String? + role UserRole @default(BUYER) + kycStatus KYCStatus @default(NONE) + kycData Json? + isActive Boolean @default(true) + deletedAt DateTime? + deletionScheduledAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - agent Agent? - listings Listing[] - savedSearches SavedSearch[] - subscription Subscription? - payments Payment[] - reviews Review[] - inquiriesSent Inquiry[] - refreshTokens RefreshToken[] - oauthAccounts OAuthAccount[] - buyerTransactions Transaction[] @relation("BuyerTransactions") + agent Agent? + listings Listing[] + savedSearches SavedSearch[] + subscription Subscription? + payments Payment[] + reviews Review[] + inquiriesSent Inquiry[] + refreshTokens RefreshToken[] + oauthAccounts OAuthAccount[] + buyerTransactions Transaction[] @relation("BuyerTransactions") @@index([role]) @@index([kycStatus]) @@ -64,7 +64,6 @@ model User { @@index([deletedAt]) @@index([deletionScheduledAt]) @@index([createdAt]) - // --- Compound indexes (query optimization) --- @@index([role, isActive, createdAt(sort: Desc)]) @@index([kycStatus, createdAt]) @@ -76,14 +75,14 @@ enum OAuthProvider { } model RefreshToken { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - token String @unique + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + token String @unique family String expiresAt DateTime revokedAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId]) @@index([family]) @@ -110,14 +109,14 @@ model OAuthAccount { model Agent { id String @id @default(cuid()) userId String @unique - user User @relation(fields: [userId], references: [id]) + 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"] + serviceAreas Json // ["quan-1", "quan-7", "thu-duc"] isVerified Boolean @default(false) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -170,10 +169,10 @@ enum Direction { } model Property { - id String @id @default(cuid()) + id String @id @default(cuid()) propertyType PropertyType title String - description String @db.Text + description String @db.Text address String ward String district String @@ -193,8 +192,8 @@ model Property { nearbyPOIs Json? metroDistanceM Float? projectName String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt listings Listing[] valuations Valuation[] @@ -204,7 +203,6 @@ model Property { @@index([propertyType]) @@index([district, city]) @@index([location], type: Gist) - // --- Compound indexes (query optimization) --- @@index([district, propertyType]) @@index([district, city, propertyType]) @@ -215,7 +213,7 @@ model PropertyMedia { propertyId String property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) url String - type String // "image" | "video" + type String // "image" | "video" order Int @default(0) caption String? aiTags Json? @@ -227,11 +225,11 @@ model PropertyMedia { model Listing { id String @id @default(cuid()) propertyId String - property Property @relation(fields: [propertyId], references: [id]) + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) agentId String? - agent Agent? @relation(fields: [agentId], references: [id]) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: SetNull) sellerId String - seller User @relation(fields: [sellerId], references: [id]) + seller User @relation(fields: [sellerId], references: [id], onDelete: Restrict) transactionType TransactionType status ListingStatus @default(DRAFT) priceVND BigInt @@ -265,7 +263,6 @@ model Listing { @@index([createdAt]) @@index([featuredUntil]) @@index([expiresAt]) - // --- Compound indexes (query optimization) --- @@index([sellerId, status, publishedAt(sort: Desc)]) @@index([agentId, status]) @@ -309,9 +306,9 @@ enum TransactionStatus { model Transaction { id String @id @default(cuid()) listingId String - listing Listing @relation(fields: [listingId], references: [id]) + listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) buyerId String - buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id]) + buyer User @relation("BuyerTransactions", fields: [buyerId], references: [id], onDelete: Restrict) status TransactionStatus @default(INQUIRY) agreedPrice BigInt? depositAmount BigInt? @@ -330,9 +327,9 @@ model Transaction { model Inquiry { id String @id @default(cuid()) listingId String - listing Listing @relation(fields: [listingId], references: [id]) + listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) message String @db.Text phone String? isRead Boolean @default(false) @@ -355,7 +352,7 @@ enum LeadStatus { model Lead { id String @id @default(cuid()) agentId String - agent Agent @relation(fields: [agentId], references: [id]) + agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade) name String phone String email String? @@ -397,20 +394,20 @@ enum PaymentType { } model Payment { - id String @id @default(cuid()) - userId String - user User @relation(fields: [userId], references: [id]) - transactionId String? - transaction Transaction? @relation(fields: [transactionId], references: [id]) - provider PaymentProvider - type PaymentType - amountVND BigInt - status PaymentStatus @default(PENDING) - providerTxId String? - callbackData Json? - idempotencyKey String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + 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) + 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]) @@ -418,7 +415,6 @@ model Payment { @@index([status]) @@index([providerTxId]) @@index([createdAt]) - // --- Compound indexes (query optimization) --- @@index([userId, status, createdAt(sort: Desc)]) @@index([userId, type, createdAt(sort: Desc)]) @@ -443,17 +439,17 @@ enum SubscriptionStatus { } model Plan { - id String @id @default(cuid()) - tier PlanTier @unique - name String - priceMonthlyVND BigInt - priceYearlyVND BigInt + id String @id @default(cuid()) + tier PlanTier @unique + name String + priceMonthlyVND BigInt + priceYearlyVND BigInt maxListings Int? - maxSavedSearches Int? + maxSavedSearches Int? maxAnalyticsQueries Int? - maxMediaUploads Int? - features Json - isActive Boolean @default(true) + maxMediaUploads Int? + features Json + isActive Boolean @default(true) subscriptions Subscription[] } @@ -461,9 +457,9 @@ model Plan { model Subscription { id String @id @default(cuid()) userId String @unique - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) planId String - plan Plan @relation(fields: [planId], references: [id]) + plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict) status SubscriptionStatus @default(ACTIVE) currentPeriodStart DateTime currentPeriodEnd DateTime @@ -480,7 +476,7 @@ model Subscription { model UsageRecord { id String @id @default(cuid()) subscriptionId String - subscription Subscription @relation(fields: [subscriptionId], references: [id]) + subscription Subscription @relation(fields: [subscriptionId], references: [id], onDelete: Cascade) metric String count Int periodStart DateTime @@ -496,7 +492,7 @@ model UsageRecord { model Valuation { id String @id @default(cuid()) propertyId String - property Property @relation(fields: [propertyId], references: [id]) + property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade) estimatedPrice BigInt confidence Float pricePerM2 Float @@ -547,18 +543,18 @@ enum NotificationStatus { } model NotificationLog { - id String @id @default(cuid()) + id String @id @default(cuid()) userId String channel NotificationChannel templateKey String subject String? - body String @db.Text + body String @db.Text metadata Json? - status NotificationStatus @default(PENDING) + status NotificationStatus @default(PENDING) errorDetail String? sentAt DateTime? readAt DateTime? - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) @@index([userId]) @@index([channel, status]) @@ -581,6 +577,48 @@ model NotificationPreference { @@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 // ============================================================================= @@ -588,7 +626,7 @@ model NotificationPreference { model Review { id String @id @default(cuid()) userId String - user User @relation(fields: [userId], references: [id]) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) targetType String targetId String rating Int