Files
goodgo-platform/prisma/schema.prisma
Ho Ngoc Hai fba536406d feat(osm): foundation — admin boundaries, POI catalog, sync orchestrator
This is the Phase 0 + Phase 1 + Phase 4 foundation of the full OSM
integration plan. It backfills three things the rest of the platform
has been faking with hardcoded tables, and gives admins one dashboard
for every OSM-sourced layer.

Phase 0 — Vietnam administrative boundaries
* New columns on vn_provinces / vn_districts / vn_wards: PostGIS
  geometry (MultiPolygon), centroid (Point), areaKm2, osmId, population,
  lastSyncedAt + GIST indexes on geometry/centroid.
* `scripts/sync-osm-admin-boundaries.ts` pulls
  `boundary=administrative + admin_level=4|6|8` from Overpass per chunk,
  filters to mainland VN via the existing country polygon, resolves the
  GSO code (or generates `OSM_<id>`), and upserts via raw SQL because
  Prisma can't manage PostGIS columns.
* `GeoLookupService` (shared module) replaces the old
  `nearestProvince()` heuristic — `lookup(lng,lat)` returns
  province/district/ward via `ST_Contains` on the GIST-indexed polygons.
* The KCN sync now resolves province/district from the polygon table
  and falls back to the centroid heuristic only when polygons aren't
  loaded yet.
* `scripts/backfill-admin-codes.ts` rewrites province/district/ward on
  IndustrialPark, ProjectDevelopment and Property using the new lookup.

Phase 1 — POI catalog (15 categories, schema only here)
* New `Poi` table with `PoiCategory` enum, OSM provenance columns,
  GIST index on `location`. New `TransportLine` for metro/highway
  multilinestrings.
* `scripts/sync-osm-poi.ts` queries Overpass per category × chunk,
  resolves province/district codes from the boundary polygons, upserts
  with `osmLocked` / `lockedFields` honour same as KCN.
* New NestJS `PoiModule` exposes:
    GET /poi/by-bbox    — GeoJSON for map overlays
    GET /poi/nearby     — sidebar "tiện ích xung quanh" (HMAC distance ranks)
    GET /poi/coverage   — admin per-category counts
* New web component `<NearbyPoiSidebar />` ready to drop into listing /
  project / KCN detail pages.

Phase 4 — Sync orchestrator + admin dashboard
* New `OsmSyncRun` audit table tracks every sync invocation
  (RUNNING / SUCCESS / PARTIAL / FAILED + row stats + error message).
* `OsmSyncService` spawns the right tsx script for any (layer, category,
  chunk) tuple, parses stats out of stdout, updates the run row.
* `OsmSyncCronService` schedules:
    Daily 02:00  → POI category rotation (1/day, 20-day cycle)
    Mon  02:30  → admin-boundaries provinces
    Wed  02:30  → admin-boundaries districts
    Sat  02:30  → admin-boundaries wards
    1st of month 03:00 → industrial-parks (per chunk)
  All gated by `OSM_SYNC_ENABLED=true`.
* New admin endpoints under `/admin/osm/*` (layers / coverage / runs /
  trigger), guarded by JWT + ADMIN role.
* New `/admin/osm` Next.js page: stat cards, coverage table with
  per-row "Sync now", recent runs list with auto-refresh every 15s.

Run on dev so far: 33 provinces + 1100+ districts (still finishing) +
305 hospitals POI imported.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:01:19 +07:00

1842 lines
57 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
/// 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")
}