// ============================================================================= // 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 /// Chủ đầu tư dự án BĐS — được admin cấp tài khoản; CRUD dự án của mình và /// xem inquiry/lead/analytics cho các dự án đó. DEVELOPER /// Đơn vị vận hành Khu Công Nghiệp — được admin cấp tài khoản; CRUD KCN và /// industrial listings của mình. PARK_OPERATOR 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? /// First login under MFA enforcement when the user had not yet enrolled. /// Used to compute the remaining grace period before enrollment becomes /// mandatory for roles in MFA_REQUIRED_ROLES (currently ADMIN). mfaGraceStartedAt DateTime? /// Last successful MFA verification (TOTP or backup code). Used by the /// admin re-auth interceptor for sensitive operations. mfaLastVerifiedAt 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[] /// Dự án BĐS do user này làm chủ đầu tư (role=DEVELOPER). ownedProjects ProjectDevelopment[] @relation("ProjectOwner") /// KCN do user này vận hành (role=PARK_OPERATOR). ownedIndustrialParks IndustrialPark[] @relation("IndustrialParkOwner") zaloAccountLink ZaloAccountLink? notificationLogs NotificationLog[] industrialListingsSelling IndustrialListing[] @relation("IndustrialListingSeller") listingFlagsReported ListingFlag[] @relation("listingFlagsReported") @@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]) } /// Zalo OA account link — stores the OA-scoped access/refresh tokens for sending /// template messages to a linked user via ZNS. /// Token fields are AES-256-GCM encrypted at the application layer. model ZaloAccountLink { id String @id @default(cuid()) userId String @unique user User @relation(fields: [userId], references: [id], onDelete: Cascade) /// Zalo user ID scoped to the Official Account (OA UID, not Social Graph UID) zaloUserId String @unique /// AES-256-GCM encrypted access token (base64url: iv.tag.ciphertext) accessToken String /// AES-256-GCM encrypted refresh token (base64url: iv.tag.ciphertext) refreshToken String expiresAt DateTime /// Unix epoch (seconds) of the last user→OA interaction; used for 24-hour window check lastInteractAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([zaloUserId]) @@index([expiresAt]) @@map("zalo_account_links") } 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[] industrialListings IndustrialListing[] @relation("IndustrialListingAgent") @@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[] suitableFor String[] @default([]) whyThisLocation String? @db.Text isVerified Boolean @default(false) /// Optional owning developer user (role=DEVELOPER). NULL for projects not /// yet assigned to a CĐT account — admin still manages those. ownerId String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt properties Property[] owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull) @@index([status]) @@index([district, city]) @@index([developer]) @@index([location], type: Gist) @@index([isVerified]) @@index([createdAt]) @@index([district, city, status]) @@index([ownerId]) } // ============================================================================= // LISTINGS // ============================================================================= enum PropertyType { APARTMENT VILLA TOWNHOUSE LAND OFFICE SHOPHOUSE ROOM_RENTAL CONDOTEL SERVICED_APARTMENT } 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 } enum Furnishing { FULLY_FURNISHED BASIC_FURNISHED UNFURNISHED } enum PropertyCondition { NEW LIKE_NEW RENOVATED USED } enum LegalStatus { SO_DO SO_HONG LAND_USE_RIGHT JOINT_USE_RIGHT AWAITING NO_CERTIFICATE } model Property { id String @id @default(cuid()) propertyType PropertyType title String description String @db.Text address String ward String district String city String /// Lower-cased, unaccented, whitespace-collapsed concatenation of /// address/ward/district/city. Used for duplicate detection (TEC-2932). /// Nullable until the backfill migration covers historic rows. addressNormalized 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 LegalStatus? certificateVerified Boolean @default(false) amenities Json? nearbyPOIs Json? metroDistanceM Float? projectName String? projectDevelopmentId String? projectDevelopment ProjectDevelopment? @relation(fields: [projectDevelopmentId], references: [id], onDelete: SetNull) furnishing Furnishing? propertyCondition PropertyCondition? balconyDirection Direction? // CHECK ("maintenanceFeeVND" IS NULL OR "maintenanceFeeVND" >= 0) maintenanceFeeVND BigInt? parkingSlots Int? viewType String[] @default([]) petFriendly Boolean? suitableFor String[] @default([]) whyThisLocation String? @db.Text 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]) @@index([addressNormalized]) // [TEC-3055] Ward-level heatmap & listing-volume drill-down @@index([ward, city]) } 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) // CHECK ("priceVND" > 0) — see migration 20260420000000_add_price_check_constraints priceVND BigInt // CHECK ("pricePerM2" IS NULL OR "pricePerM2" > 0) pricePerM2 Float? // CHECK ("rentPriceMonthly" IS NULL OR "rentPriceMonthly" > 0) rentPriceMonthly BigInt? commissionPct Float? @default(2.0) // CHECK ("aiPriceEstimate" IS NULL OR "aiPriceEstimate" > 0) aiPriceEstimate BigInt? aiConfidence Float? moderationScore Float? moderationNotes String? viewCount Int @default(0) saveCount Int @default(0) inquiryCount Int @default(0) featuredUntil DateTime? featuredPackage String? /// "3_days" | "7_days" | "30_days" expiresAt DateTime? expiryNotifiedAt DateTime? publishedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt transactions Transaction[] inquiries Inquiry[] orders Order[] priceHistories PriceHistory[] savedByUsers SavedListing[] conversations Conversation[] flags ListingFlag[] // --- 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) // CHECK ("oldPrice" > 0) — see migration 20260420000000_add_price_check_constraints oldPrice BigInt // CHECK ("newPrice" > 0) newPrice BigInt source String @default("manual_update") changedAt DateTime @default(now()) @@index([listingId, changedAt(sort: Desc)]) } // ============================================================================= // LISTING FLAGS (user-submitted abuse/scam reports) // ============================================================================= enum FlagReason { SCAM DUPLICATE WRONG_INFO ALREADY_SOLD INAPPROPRIATE } enum FlagStatus { PENDING REVIEWED DISMISSED } model ListingFlag { id String @id @default(cuid()) listingId String listing Listing @relation(fields: [listingId], references: [id], onDelete: Cascade) reporterId String reporter User @relation("listingFlagsReported", fields: [reporterId], references: [id], onDelete: Restrict) reason FlagReason description String? /// Mô tả chi tiết (tuỳ chọn) status FlagStatus @default(PENDING) reviewedBy String? reviewedAt DateTime? reviewNotes String? createdAt DateTime @default(now()) @@unique([listingId, reporterId]) // one report per user per listing @@index([listingId]) @@index([status, createdAt(sort: Desc)]) @@index([reporterId]) @@map("listing_flags") } // ============================================================================= // 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 @@unique([subscriptionId, metric, periodStart, periodEnd]) @@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 user User @relation(fields: [userId], references: [id], onDelete: Cascade) 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)]) } // Free-form moderation audit log capturing every approve/reject/edit/flag action // performed by moderators on listings, properties, inquiries and other targets. // Strings (not enums) are used for `targetType` and `action` so that adding new // moderation surfaces does not require an enum migration. Existing AdminAuditLog // stays as-is for the admin-action timeline; this table is the moderator-centric // view used by TEC-2926. model ModerationAuditLog { id String @id @default(uuid()) targetType String targetId String action String moderatorId String reason String? metadata Json? createdAt DateTime @default(now()) @@index([targetType, targetId]) @@index([moderatorId, createdAt(sort: Desc)]) @@index([action, createdAt(sort: Desc)]) @@index([createdAt]) } // ============================================================================= // 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 } /// OSM element type — way/relation are most common for industrial parks /// (polygon boundaries), node only used when the park has no traced area. enum IndustrialParkOsmType { NODE WAY RELATION } /// Provenance of an IndustrialPark row. Used to filter what's shown on the /// public KCN list (only MANUAL + OSM_PROMOTED) versus the admin queue /// (everything, including raw OSM imports). enum IndustrialParkDataSource { /// Human-curated by goodgo team; full business data (rents, fees, media). MANUAL /// Imported from OpenStreetMap, not yet vetted. Hidden from public list. OSM /// Imported from OSM and reviewed by an admin who promoted it to the /// public catalog. Geometry/name still tracked against OSM via osmId. OSM_PROMOTED } 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 Decimal? @db.Decimal(18, 4) rbfRentUsdM2Month Decimal? @db.Decimal(18, 4) rbwRentUsdM2Month Decimal? @db.Decimal(18, 4) managementFeeUsd Decimal? @db.Decimal(18, 4) 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) /// Optional owning operator user (role=PARK_OPERATOR). NULL for parks not /// yet assigned to an operator account — admin still manages those. ownerId String? // ─── OSM provenance & sync state ───────────────────────────────────────── /// Marker for where this row came from. Drives public visibility + /// conflict-resolution policy during OSM sync. dataSource IndustrialParkDataSource @default(MANUAL) /// Hidden from the public list when false. OSM-imported rows default to /// false until an admin promotes them; MANUAL rows default to true. isPublic Boolean @default(true) /// OpenStreetMap entity that this row mirrors (NULL for purely manual rows). /// `osmId` is unique because OSM ids are scoped per-type, but in practice /// most industrial parks are `way` so collisions are vanishingly rare. osmType IndustrialParkOsmType? osmId BigInt? @unique /// OSM `version` tag. Used during incremental sync to detect remote edits. osmVersion Int? /// Full OSM tag bag, kept as JSONB for flexibility (we don't model every /// possible tag — operator, website, addr:*, source, etc.). osmTags Json? /// Polygon outline of the park as a MultiPolygon. NULL when the OSM entity /// is a node (no traced area) or when sourced from a manual seed without /// boundary tracing. `location` (Point) remains the centroid for low-zoom /// rendering. boundary Unsupported("geometry(MultiPolygon, 4326)")? /// When true the OSM sync cron skips this row entirely (admin froze it). /// Useful for parks where OSM tag noise would overwrite curated data. osmLocked Boolean @default(false) /// Per-field lock list. Even when `osmLocked = false`, the sync cron /// preserves any column whose name appears here. Lets admins fix one /// field (e.g. `name`) without freezing the whole row. lockedFields String[] @default([]) /// Last successful Overpass/PBF reconcile pass; NULL means never synced. lastSyncedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt listings IndustrialListing[] owner User? @relation("IndustrialParkOwner", fields: [ownerId], references: [id], onDelete: SetNull) @@index([status]) @@index([province]) @@index([region]) @@index([developer]) @@index([location], type: Gist) @@index([isVerified]) @@index([occupancyRate]) @@index([landRentUsdM2Year]) @@index([region, province, status]) @@index([createdAt]) @@index([ownerId]) // OSM sync access patterns @@index([osmId]) @@index([dataSource, isPublic]) @@index([boundary], type: Gist) @@index([lastSyncedAt]) } model IndustrialListing { id String @id @default(cuid()) parkId String park IndustrialPark @relation(fields: [parkId], references: [id], onDelete: Cascade) agentId String? agent Agent? @relation("IndustrialListingAgent", fields: [agentId], references: [id], onDelete: SetNull) sellerId String seller User @relation("IndustrialListingSeller", fields: [sellerId], references: [id], onDelete: Restrict) 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 Decimal? @db.Decimal(18, 4) pricingUnit String? // "usd/m2/month", "usd/m2/year" totalLeasePrice Decimal? @db.Decimal(18, 4) managementFee Decimal? @db.Decimal(18, 4) 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? listing Listing? @relation(fields: [listingId], references: [id], onDelete: SetNull) 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]) } // ============================================================================= // SYSTEM SETTINGS // ============================================================================= // Key/value store for runtime-configurable system settings (e.g. AI provider // credentials). Values are persisted as plain strings — TODO: encrypt `isSecret` // entries at rest as a future hardening step. model SystemSetting { key String @id value String @db.Text valueType String @default("string") // "string" | "secret" | "number" | "boolean" isSecret Boolean @default(false) updatedAt DateTime @updatedAt updatedBy String? } // ============================================================================= // VIETNAM ADMINISTRATIVE REFERENCE (ĐVHCVN) // ============================================================================= // Authoritative 3-level administrative hierarchy sourced from GSO // (danhmuchanhchinhvn.gso.gov.vn): 63 provinces / ~705 districts / ~10.6K wards. // Seeded from `prisma/data/vn-admin/` snapshot via `prisma/seed-vn-admin.ts`. // [GOO-21] model VnProvince { code String @id // GSO province code, zero-padded (e.g. "01", "79") name String // Canonical Vietnamese name, e.g. "Thành phố Hồ Chí Minh" nameEn String? type String // "Thành phố Trung ương" | "Tỉnh" codename String // slug, e.g. "thanh_pho_ho_chi_minh" phoneCode Int? /// OSM relation id for `boundary=administrative + admin_level=4`. Null until first sync. osmId BigInt? @unique /// PostGIS multipolygon (managed via raw SQL — Prisma can't model PostGIS). geometry Unsupported("geometry(MultiPolygon, 4326)")? /// Cached centroid for fast "show on map" without ST_Centroid every query. centroid Unsupported("geometry(Point, 4326)")? /// Surface area in km². Useful for density / coverage analytics. areaKm2 Float? /// Latest GSO population estimate when known. population Int? /// When the row was last refreshed from Overpass. lastSyncedAt DateTime? updatedAt DateTime @updatedAt districts VnDistrict[] @@index([codename]) @@index([geometry], type: Gist) @@index([centroid], type: Gist) @@index([lastSyncedAt]) @@map("vn_provinces") } model VnDistrict { code String @id // GSO district code provinceCode String name String // e.g. "Quận 1", "Huyện Củ Chi", "Thành phố Thủ Đức" nameEn String? type String // "Quận" | "Huyện" | "Thị xã" | "Thành phố thuộc tỉnh" codename String osmId BigInt? @unique geometry Unsupported("geometry(MultiPolygon, 4326)")? centroid Unsupported("geometry(Point, 4326)")? areaKm2 Float? population Int? lastSyncedAt DateTime? updatedAt DateTime @updatedAt province VnProvince @relation(fields: [provinceCode], references: [code], onDelete: Restrict) wards VnWard[] @@index([provinceCode]) @@index([codename]) @@index([geometry], type: Gist) @@index([centroid], type: Gist) @@index([lastSyncedAt]) @@map("vn_districts") } model VnWard { code String @id districtCode String name String nameEn String? type String // "Phường" | "Xã" | "Thị trấn" codename String osmId BigInt? @unique geometry Unsupported("geometry(MultiPolygon, 4326)")? centroid Unsupported("geometry(Point, 4326)")? areaKm2 Float? population Int? lastSyncedAt DateTime? updatedAt DateTime @updatedAt district VnDistrict @relation(fields: [districtCode], references: [code], onDelete: Restrict) @@index([districtCode]) @@index([codename]) @@index([geometry], type: Gist) @@index([centroid], type: Gist) @@index([lastSyncedAt]) @@map("vn_wards") } /// Historical name/code changes so legacy data (e.g. Quận 2, Quận 9) and post-2025 /// merges can still resolve to the current district/ward. /// Categories of OSM POI we ingest. Each maps to one or more Overpass /// tag queries — see `scripts/sync-osm-poi.ts`. Adding a new value here /// requires a Prisma migration. enum PoiCategory { // Education SCHOOL_PRIMARY SCHOOL_SECONDARY UNIVERSITY // Health HOSPITAL CLINIC PHARMACY // Commerce MARKET SUPERMARKET MALL CONVENIENCE // Finance BANK ATM // Recreation PARK // Services GAS_STATION POLICE POST_OFFICE // Transport (also tracked here for proximity scoring; lines live in TransportLine) METRO_STATION RAILWAY_STATION BUS_STATION AIRPORT } enum OsmType { NODE WAY RELATION } enum OsmDataSource { OSM OSM_PROMOTED MANUAL } /// Catalog of points-of-interest sourced primarily from OSM. Backs the /// "tiện ích xung quanh" feature on listing detail + KCN + project /// proximity scoring + the search "within X meters" filters. model Poi { id String @id @default(cuid()) category PoiCategory name String nameEn String? /// PostGIS Point — managed via raw SQL because Prisma can't model /// `geometry`. GIST-indexed for fast nearby-radius queries. location Unsupported("geometry(Point, 4326)") address String? /// Resolved by `GeoLookupService` after insert (not part of OSM data). provinceCode String? districtCode String? wardCode String? /// OSM provenance — same model as IndustrialPark. osmId BigInt @unique osmType OsmType osmTags Json dataSource OsmDataSource @default(OSM) isPublic Boolean @default(true) osmLocked Boolean @default(false) lockedFields String[] @default([]) lastSyncedAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([location], type: Gist) @@index([category, provinceCode]) @@index([category, districtCode]) @@index([provinceCode]) @@index([dataSource, isPublic]) @@index([lastSyncedAt]) @@map("Poi") } /// Transport lines (metro / railway / highway routes) — the linear /// counterpart to Poi station entries. Used to compute "distance to /// nearest metro line" without joining 100k station pings. model TransportLine { id String @id @default(cuid()) type String // METRO | RAILWAY | TRUNK | MOTORWAY | PRIMARY name String // "Metro Số 1 Bến Thành - Suối Tiên" / "QL1A" ref String? // "M1", "QL1A" geometry Unsupported("geometry(MultiLineString, 4326)") osmRelationId BigInt? @unique status String @default("operational") // planned | under_construction | operational lengthKm Float? lastSyncedAt DateTime createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@index([geometry], type: Gist) @@index([type]) @@index([status]) @@map("TransportLine") } enum OsmSyncStatus { RUNNING SUCCESS PARTIAL FAILED } /// Audit + monitoring record for every OSM sync run (admin boundaries, /// POI categories, transport, KCN, etc.). Drives the `/admin/osm` /// dashboard and Prometheus alerts. model OsmSyncRun { id String @id @default(cuid()) /// Coarse layer name: "admin-boundaries" / "poi" / "transport" / "industrial-parks" layer String /// Fine-grained scope inside the layer, when applicable. category String? chunk String? startedAt DateTime @default(now()) finishedAt DateTime? status OsmSyncStatus @default(RUNNING) rowsAdded Int @default(0) rowsUpdated Int @default(0) rowsSkipped Int @default(0) rowsLocked Int @default(0) /// Truncated message for UI display; full stack lives in Loki. errorMessage String? @db.Text /// SHA-256 of the Overpass query so we can detect query drift. overpassQueryHash String? /// Free-form metadata (Overpass response size, kubectl run id, etc.). metadata Json? @@index([layer, startedAt]) @@index([status]) @@index([startedAt]) @@map("OsmSyncRun") } model VnAdministrativeAlias { id String @id @default(cuid()) oldCode String? // GSO code pre-change, when known oldName String // human-readable legacy name, e.g. "Quận 2" level String // "province" | "district" | "ward" newDistrictCode String? newWardCode String? reason String // e.g. "merged_into_thu_duc_2021", "2025_redistrict" mergedAt DateTime? createdAt DateTime @default(now()) @@index([oldName]) @@index([newDistrictCode]) @@index([newWardCode]) @@map("vn_administrative_aliases") } /// Transactional outbox for RFC-004 async messaging backbone (GOO-95). /// Producers write one row per domain event in the same Postgres /// transaction as the domain state change. A single relay process /// (Postgres advisory-lock leader) tails pending rows and publishes /// them to Redis Streams, flipping `publishedAt` on success. model EventOutbox { id String @id @default(cuid()) /// UUIDv7 from the envelope — idempotency key + stable cross-runtime id. eventId String @unique /// Dotted event type (`payment.completed`). Used by the relay to route. eventType String /// Aggregate identifier (e.g. paymentId, listingId) — for partitioning / debugging. aggregateId String? /// Fully-formed `EventEnvelope` JSON ready to XADD. Never mutated after insert. envelope Json /// When the row was inserted (inside the domain tx). createdAt DateTime @default(now()) /// When the relay confirmed XADD acceptance. Null = still pending. publishedAt DateTime? /// Monotonic retry count for the relay (reset on success). attempts Int @default(0) /// Last error message on failure — surfaced in admin dashboards / Sentry. lastError String? @@index([publishedAt, createdAt]) @@index([eventType, createdAt]) @@map("event_outbox") }