diff --git a/prisma/migrations/20260407165528_init/migration.sql b/prisma/migrations/20260407165528_init/migration.sql new file mode 100644 index 0000000..ae6424d --- /dev/null +++ b/prisma/migrations/20260407165528_init/migration.sql @@ -0,0 +1,468 @@ +-- CreateExtension +CREATE EXTENSION IF NOT EXISTS "postgis"; + +-- CreateEnum +CREATE TYPE "UserRole" AS ENUM ('BUYER', 'SELLER', 'AGENT', 'ADMIN'); + +-- CreateEnum +CREATE TYPE "KYCStatus" AS ENUM ('NONE', 'PENDING', 'VERIFIED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "PropertyType" AS ENUM ('APARTMENT', 'VILLA', 'TOWNHOUSE', 'LAND', 'OFFICE', 'SHOPHOUSE'); + +-- CreateEnum +CREATE TYPE "TransactionType" AS ENUM ('SALE', 'RENT'); + +-- CreateEnum +CREATE TYPE "ListingStatus" AS ENUM ('DRAFT', 'PENDING_REVIEW', 'ACTIVE', 'RESERVED', 'SOLD', 'RENTED', 'EXPIRED', 'REJECTED'); + +-- CreateEnum +CREATE TYPE "Direction" AS ENUM ('NORTH', 'SOUTH', 'EAST', 'WEST', 'NORTHEAST', 'NORTHWEST', 'SOUTHEAST', 'SOUTHWEST'); + +-- CreateEnum +CREATE TYPE "TransactionStatus" AS ENUM ('INQUIRY', 'VIEWING_SCHEDULED', 'OFFER_MADE', 'DEPOSIT_PAID', 'CONTRACT_SIGNING', 'COMPLETED', 'CANCELLED'); + +-- CreateEnum +CREATE TYPE "PaymentProvider" AS ENUM ('VNPAY', 'MOMO', 'ZALOPAY', 'BANK_TRANSFER'); + +-- CreateEnum +CREATE TYPE "PaymentStatus" AS ENUM ('PENDING', 'PROCESSING', 'COMPLETED', 'FAILED', 'REFUNDED'); + +-- CreateEnum +CREATE TYPE "PaymentType" AS ENUM ('SUBSCRIPTION', 'LISTING_FEE', 'DEPOSIT', 'FEATURED_LISTING'); + +-- CreateEnum +CREATE TYPE "PlanTier" AS ENUM ('FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'); + +-- CreateEnum +CREATE TYPE "SubscriptionStatus" AS ENUM ('ACTIVE', 'PAST_DUE', 'CANCELLED', 'EXPIRED'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT, + "phone" TEXT NOT NULL, + "passwordHash" TEXT, + "fullName" TEXT NOT NULL, + "avatarUrl" TEXT, + "role" "UserRole" NOT NULL DEFAULT 'BUYER', + "kycStatus" "KYCStatus" NOT NULL DEFAULT 'NONE', + "kycData" JSONB, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "User_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Agent" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "licenseNumber" TEXT, + "agency" TEXT, + "qualityScore" DOUBLE PRECISION NOT NULL DEFAULT 0, + "totalDeals" INTEGER NOT NULL DEFAULT 0, + "responseTimeAvg" INTEGER, + "bio" TEXT, + "serviceAreas" JSONB NOT NULL, + "isVerified" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Agent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Property" ( + "id" TEXT NOT NULL, + "propertyType" "PropertyType" NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "address" TEXT NOT NULL, + "ward" TEXT NOT NULL, + "district" TEXT NOT NULL, + "city" TEXT NOT NULL, + "location" geometry(Point, 4326) NOT NULL, + "areaM2" DOUBLE PRECISION NOT NULL, + "usableAreaM2" DOUBLE PRECISION, + "bedrooms" INTEGER, + "bathrooms" INTEGER, + "floors" INTEGER, + "floor" INTEGER, + "totalFloors" INTEGER, + "direction" "Direction", + "yearBuilt" INTEGER, + "legalStatus" TEXT, + "amenities" JSONB, + "nearbyPOIs" JSONB, + "metroDistanceM" DOUBLE PRECISION, + "projectName" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Property_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "PropertyMedia" ( + "id" TEXT NOT NULL, + "propertyId" TEXT NOT NULL, + "url" TEXT NOT NULL, + "type" TEXT NOT NULL, + "order" INTEGER NOT NULL DEFAULT 0, + "caption" TEXT, + "aiTags" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PropertyMedia_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Listing" ( + "id" TEXT NOT NULL, + "propertyId" TEXT NOT NULL, + "agentId" TEXT, + "sellerId" TEXT NOT NULL, + "transactionType" "TransactionType" NOT NULL, + "status" "ListingStatus" NOT NULL DEFAULT 'DRAFT', + "priceVND" BIGINT NOT NULL, + "pricePerM2" DOUBLE PRECISION, + "rentPriceMonthly" BIGINT, + "commissionPct" DOUBLE PRECISION DEFAULT 2.0, + "aiPriceEstimate" BIGINT, + "aiConfidence" DOUBLE PRECISION, + "moderationScore" DOUBLE PRECISION, + "moderationNotes" TEXT, + "viewCount" INTEGER NOT NULL DEFAULT 0, + "saveCount" INTEGER NOT NULL DEFAULT 0, + "inquiryCount" INTEGER NOT NULL DEFAULT 0, + "featuredUntil" TIMESTAMP(3), + "expiresAt" TIMESTAMP(3), + "publishedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Listing_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SavedSearch" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "filters" JSONB NOT NULL, + "alertEnabled" BOOLEAN NOT NULL DEFAULT true, + "lastAlertAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SavedSearch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Transaction" ( + "id" TEXT NOT NULL, + "listingId" TEXT NOT NULL, + "buyerId" TEXT NOT NULL, + "status" "TransactionStatus" NOT NULL DEFAULT 'INQUIRY', + "agreedPrice" BIGINT, + "depositAmount" BIGINT, + "timeline" JSONB, + "contractUrl" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Transaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Inquiry" ( + "id" TEXT NOT NULL, + "listingId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "message" TEXT NOT NULL, + "phone" TEXT, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Inquiry_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Lead" ( + "id" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "phone" TEXT NOT NULL, + "email" TEXT, + "source" TEXT NOT NULL, + "score" DOUBLE PRECISION, + "notes" JSONB, + "status" TEXT NOT NULL DEFAULT 'new', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Lead_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Payment" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "transactionId" TEXT, + "provider" "PaymentProvider" NOT NULL, + "type" "PaymentType" NOT NULL, + "amountVND" BIGINT NOT NULL, + "status" "PaymentStatus" NOT NULL DEFAULT 'PENDING', + "providerTxId" TEXT, + "callbackData" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Payment_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Plan" ( + "id" TEXT NOT NULL, + "tier" "PlanTier" NOT NULL, + "name" TEXT NOT NULL, + "priceMonthlyVND" BIGINT NOT NULL, + "priceYearlyVND" BIGINT NOT NULL, + "maxListings" INTEGER, + "maxSavedSearches" INTEGER, + "features" JSONB NOT NULL, + "isActive" BOOLEAN NOT NULL DEFAULT true, + + CONSTRAINT "Plan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Subscription" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "planId" TEXT NOT NULL, + "status" "SubscriptionStatus" NOT NULL DEFAULT 'ACTIVE', + "currentPeriodStart" TIMESTAMP(3) NOT NULL, + "currentPeriodEnd" TIMESTAMP(3) NOT NULL, + "cancelledAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Subscription_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "UsageRecord" ( + "id" TEXT NOT NULL, + "subscriptionId" TEXT NOT NULL, + "metric" TEXT NOT NULL, + "count" INTEGER NOT NULL, + "periodStart" TIMESTAMP(3) NOT NULL, + "periodEnd" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UsageRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Valuation" ( + "id" TEXT NOT NULL, + "propertyId" TEXT NOT NULL, + "estimatedPrice" BIGINT NOT NULL, + "confidence" DOUBLE PRECISION NOT NULL, + "pricePerM2" DOUBLE PRECISION NOT NULL, + "comparables" JSONB NOT NULL, + "features" JSONB NOT NULL, + "modelVersion" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Valuation_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MarketIndex" ( + "id" TEXT NOT NULL, + "district" TEXT NOT NULL, + "city" TEXT NOT NULL, + "propertyType" "PropertyType" NOT NULL, + "period" TEXT NOT NULL, + "medianPrice" BIGINT NOT NULL, + "avgPriceM2" DOUBLE PRECISION NOT NULL, + "totalListings" INTEGER NOT NULL, + "daysOnMarket" DOUBLE PRECISION NOT NULL, + "inventoryLevel" INTEGER NOT NULL, + "absorptionRate" DOUBLE PRECISION, + "yoyChange" DOUBLE PRECISION, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MarketIndex_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Review" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "targetType" TEXT NOT NULL, + "targetId" TEXT NOT NULL, + "rating" INTEGER NOT NULL, + "comment" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Review_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "User_phone_key" ON "User"("phone"); + +-- CreateIndex +CREATE INDEX "User_phone_idx" ON "User"("phone"); + +-- CreateIndex +CREATE INDEX "User_role_idx" ON "User"("role"); + +-- CreateIndex +CREATE UNIQUE INDEX "Agent_userId_key" ON "Agent"("userId"); + +-- CreateIndex +CREATE INDEX "Agent_qualityScore_idx" ON "Agent"("qualityScore"); + +-- CreateIndex +CREATE INDEX "Agent_isVerified_idx" ON "Agent"("isVerified"); + +-- CreateIndex +CREATE INDEX "Property_propertyType_idx" ON "Property"("propertyType"); + +-- CreateIndex +CREATE INDEX "Property_district_city_idx" ON "Property"("district", "city"); + +-- CreateIndex +CREATE INDEX "Property_location_idx" ON "Property" USING GIST ("location"); + +-- CreateIndex +CREATE INDEX "PropertyMedia_propertyId_idx" ON "PropertyMedia"("propertyId"); + +-- CreateIndex +CREATE INDEX "Listing_status_idx" ON "Listing"("status"); + +-- CreateIndex +CREATE INDEX "Listing_transactionType_idx" ON "Listing"("transactionType"); + +-- CreateIndex +CREATE INDEX "Listing_priceVND_idx" ON "Listing"("priceVND"); + +-- CreateIndex +CREATE INDEX "Listing_agentId_idx" ON "Listing"("agentId"); + +-- CreateIndex +CREATE INDEX "Listing_publishedAt_idx" ON "Listing"("publishedAt"); + +-- CreateIndex +CREATE INDEX "SavedSearch_userId_idx" ON "SavedSearch"("userId"); + +-- CreateIndex +CREATE INDEX "Transaction_listingId_idx" ON "Transaction"("listingId"); + +-- CreateIndex +CREATE INDEX "Transaction_buyerId_idx" ON "Transaction"("buyerId"); + +-- CreateIndex +CREATE INDEX "Transaction_status_idx" ON "Transaction"("status"); + +-- CreateIndex +CREATE INDEX "Inquiry_listingId_idx" ON "Inquiry"("listingId"); + +-- CreateIndex +CREATE INDEX "Inquiry_userId_idx" ON "Inquiry"("userId"); + +-- CreateIndex +CREATE INDEX "Lead_agentId_idx" ON "Lead"("agentId"); + +-- CreateIndex +CREATE INDEX "Lead_status_idx" ON "Lead"("status"); + +-- CreateIndex +CREATE INDEX "Payment_userId_idx" ON "Payment"("userId"); + +-- CreateIndex +CREATE INDEX "Payment_status_idx" ON "Payment"("status"); + +-- CreateIndex +CREATE INDEX "Payment_providerTxId_idx" ON "Payment"("providerTxId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Plan_tier_key" ON "Plan"("tier"); + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_userId_key" ON "Subscription"("userId"); + +-- CreateIndex +CREATE INDEX "Subscription_status_idx" ON "Subscription"("status"); + +-- CreateIndex +CREATE INDEX "UsageRecord_subscriptionId_metric_idx" ON "UsageRecord"("subscriptionId", "metric"); + +-- CreateIndex +CREATE INDEX "Valuation_propertyId_idx" ON "Valuation"("propertyId"); + +-- CreateIndex +CREATE INDEX "MarketIndex_city_period_idx" ON "MarketIndex"("city", "period"); + +-- CreateIndex +CREATE UNIQUE INDEX "MarketIndex_district_city_propertyType_period_key" ON "MarketIndex"("district", "city", "propertyType", "period"); + +-- CreateIndex +CREATE INDEX "Review_targetType_targetId_idx" ON "Review"("targetType", "targetId"); + +-- AddForeignKey +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PropertyMedia" ADD CONSTRAINT "PropertyMedia_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Listing" ADD CONSTRAINT "Listing_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Listing" ADD CONSTRAINT "Listing_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Listing" ADD CONSTRAINT "Listing_sellerId_fkey" FOREIGN KEY ("sellerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SavedSearch" ADD CONSTRAINT "SavedSearch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Transaction" ADD CONSTRAINT "Transaction_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Inquiry" ADD CONSTRAINT "Inquiry_listingId_fkey" FOREIGN KEY ("listingId") REFERENCES "Listing"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Inquiry" ADD CONSTRAINT "Inquiry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Lead" ADD CONSTRAINT "Lead_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Payment" ADD CONSTRAINT "Payment_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_planId_fkey" FOREIGN KEY ("planId") REFERENCES "Plan"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "UsageRecord" ADD CONSTRAINT "UsageRecord_subscriptionId_fkey" FOREIGN KEY ("subscriptionId") REFERENCES "Subscription"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Valuation" ADD CONSTRAINT "Valuation_propertyId_fkey" FOREIGN KEY ("propertyId") REFERENCES "Property"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Review" ADD CONSTRAINT "Review_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..ffc958b --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,786 @@ +import { + PrismaClient, + UserRole, + PlanTier, + PropertyType, + TransactionType, + ListingStatus, + Direction, +} from '@prisma/client'; + +const prisma = new PrismaClient(); + +// ============================================================================= +// Districts & Wards — HCM & Hanoi +// ============================================================================= + +const HCM_DISTRICTS = [ + { + district: 'Quận 1', + wards: [ + 'Bến Nghé', + 'Bến Thành', + 'Cầu Kho', + 'Cầu Ông Lãnh', + 'Cô Giang', + 'Đa Kao', + 'Nguyễn Cư Trinh', + 'Nguyễn Thái Bình', + 'Phạm Ngũ Lão', + 'Tân Định', + ], + }, + { + district: 'Quận 3', + wards: [ + 'Phường 1', + 'Phường 2', + 'Phường 3', + 'Phường 4', + 'Phường 5', + 'Phường 9', + 'Phường 10', + 'Phường 11', + 'Phường 12', + 'Phường 13', + 'Phường 14', + 'Võ Thị Sáu', + ], + }, + { + district: 'Quận 7', + wards: [ + 'Bình Thuận', + 'Phú Mỹ', + 'Phú Thuận', + 'Tân Hưng', + 'Tân Kiểng', + 'Tân Phong', + 'Tân Phú', + 'Tân Quy', + 'Tân Thuận Đông', + 'Tân Thuận Tây', + ], + }, + { + district: 'Thủ Đức', + wards: [ + 'An Khánh', + 'An Lợi Đông', + 'An Phú', + 'Bình Chiểu', + 'Bình Thọ', + 'Bình Trưng Đông', + 'Bình Trưng Tây', + 'Cát Lái', + 'Hiệp Bình Chánh', + 'Hiệp Bình Phước', + 'Linh Chiểu', + 'Linh Đông', + 'Linh Tây', + 'Linh Trung', + 'Linh Xuân', + 'Long Bình', + 'Long Phước', + 'Long Thạnh Mỹ', + 'Long Trường', + 'Phú Hữu', + 'Phước Bình', + 'Phước Long A', + 'Phước Long B', + 'Tam Bình', + 'Tam Phú', + 'Tân Phú', + 'Thạnh Mỹ Lợi', + 'Thảo Điền', + 'Thủ Thiêm', + 'Trường Thạnh', + 'Trường Thọ', + ], + }, + { + district: 'Quận Bình Thạnh', + wards: [ + 'Phường 1', + 'Phường 2', + 'Phường 3', + 'Phường 5', + 'Phường 6', + 'Phường 7', + 'Phường 11', + 'Phường 12', + 'Phường 13', + 'Phường 14', + 'Phường 15', + 'Phường 17', + 'Phường 19', + 'Phường 21', + 'Phường 22', + 'Phường 24', + 'Phường 25', + 'Phường 26', + 'Phường 27', + 'Phường 28', + ], + }, + { + district: 'Quận Phú Nhuận', + wards: [ + 'Phường 1', + 'Phường 2', + 'Phường 3', + 'Phường 4', + 'Phường 5', + 'Phường 7', + 'Phường 8', + 'Phường 9', + 'Phường 10', + 'Phường 11', + 'Phường 13', + 'Phường 15', + 'Phường 17', + ], + }, + { + district: 'Quận Tân Bình', + wards: [ + 'Phường 1', + 'Phường 2', + 'Phường 3', + 'Phường 4', + 'Phường 5', + 'Phường 6', + 'Phường 7', + 'Phường 8', + 'Phường 9', + 'Phường 10', + 'Phường 11', + 'Phường 12', + 'Phường 13', + 'Phường 14', + 'Phường 15', + ], + }, + { + district: 'Quận Gò Vấp', + wards: [ + 'Phường 1', + 'Phường 3', + 'Phường 4', + 'Phường 5', + 'Phường 6', + 'Phường 7', + 'Phường 8', + 'Phường 9', + 'Phường 10', + 'Phường 11', + 'Phường 12', + 'Phường 13', + 'Phường 14', + 'Phường 15', + 'Phường 16', + 'Phường 17', + ], + }, +]; + +const HANOI_DISTRICTS = [ + { + district: 'Hoàn Kiếm', + wards: [ + 'Chương Dương', + 'Cửa Đông', + 'Cửa Nam', + 'Đồng Xuân', + 'Hàng Bạc', + 'Hàng Bài', + 'Hàng Bồ', + 'Hàng Bông', + 'Hàng Buồm', + 'Hàng Đào', + 'Hàng Gai', + 'Hàng Mã', + 'Hàng Trống', + 'Lý Thái Tổ', + 'Phan Chu Trinh', + 'Phúc Tân', + 'Tràng Tiền', + 'Trần Hưng Đạo', + ], + }, + { + district: 'Ba Đình', + wards: [ + 'Cống Vị', + 'Điện Biên', + 'Đội Cấn', + 'Giảng Võ', + 'Kim Mã', + 'Liễu Giai', + 'Ngọc Hà', + 'Ngọc Khánh', + 'Nguyễn Trung Trực', + 'Phúc Xá', + 'Quán Thánh', + 'Thành Công', + 'Trúc Bạch', + 'Vĩnh Phúc', + ], + }, + { + district: 'Đống Đa', + wards: [ + 'Cát Linh', + 'Hàng Bột', + 'Khâm Thiên', + 'Khương Thượng', + 'Kim Liên', + 'Láng Hạ', + 'Láng Thượng', + 'Nam Đồng', + 'Ngã Tư Sở', + 'Ô Chợ Dừa', + 'Phương Liên', + 'Phương Mai', + 'Quang Trung', + 'Quốc Tử Giám', + 'Thổ Quan', + 'Trung Liệt', + 'Trung Phụng', + 'Trung Tự', + 'Văn Chương', + 'Văn Miếu', + ], + }, + { + district: 'Hai Bà Trưng', + wards: [ + 'Bách Khoa', + 'Bạch Đằng', + 'Bạch Mai', + 'Bùi Thị Xuân', + 'Cầu Dền', + 'Đồng Mác', + 'Đồng Nhân', + 'Đồng Tâm', + 'Lê Đại Hành', + 'Minh Khai', + 'Ngô Thì Nhậm', + 'Nguyễn Du', + 'Phạm Đình Hổ', + 'Phố Huế', + 'Quỳnh Lôi', + 'Quỳnh Mai', + 'Thanh Lương', + 'Thanh Nhàn', + 'Trương Định', + 'Vĩnh Tuy', + ], + }, + { + district: 'Cầu Giấy', + wards: [ + 'Dịch Vọng', + 'Dịch Vọng Hậu', + 'Mai Dịch', + 'Nghĩa Đô', + 'Nghĩa Tân', + 'Quan Hoa', + 'Trung Hòa', + 'Yên Hòa', + ], + }, + { + district: 'Tây Hồ', + wards: [ + 'Bưởi', + 'Nhật Tân', + 'Phú Thượng', + 'Quảng An', + 'Thụy Khuê', + 'Tứ Liên', + 'Xuân La', + 'Yên Phụ', + ], + }, + { + district: 'Nam Từ Liêm', + wards: [ + 'Cầu Diễn', + 'Đại Mỗ', + 'Mễ Trì', + 'Mỹ Đình 1', + 'Mỹ Đình 2', + 'Phú Đô', + 'Phương Canh', + 'Tây Mỗ', + 'Trung Văn', + 'Xuân Phương', + ], + }, +]; + +// ============================================================================= +// Subscription Plans +// ============================================================================= + +const PLANS = [ + { + tier: PlanTier.FREE, + name: 'Miễn phí', + priceMonthlyVND: BigInt(0), + priceYearlyVND: BigInt(0), + maxListings: 3, + maxSavedSearches: 5, + features: { + basicSearch: true, + listingPost: true, + maxPhotos: 5, + analytics: false, + prioritySupport: false, + aiValuation: false, + featuredListing: false, + }, + }, + { + tier: PlanTier.AGENT_PRO, + name: 'Agent Pro', + priceMonthlyVND: BigInt(499_000), + priceYearlyVND: BigInt(4_990_000), + maxListings: 50, + maxSavedSearches: 30, + features: { + basicSearch: true, + listingPost: true, + maxPhotos: 30, + analytics: true, + prioritySupport: true, + aiValuation: true, + featuredListing: true, + leadManagement: true, + agentProfile: true, + }, + }, + { + tier: PlanTier.INVESTOR, + name: 'Investor', + priceMonthlyVND: BigInt(999_000), + priceYearlyVND: BigInt(9_990_000), + maxListings: 20, + maxSavedSearches: 100, + features: { + basicSearch: true, + listingPost: true, + maxPhotos: 15, + analytics: true, + prioritySupport: true, + aiValuation: true, + featuredListing: false, + marketReports: true, + priceAlerts: true, + portfolioTracking: true, + }, + }, + { + tier: PlanTier.ENTERPRISE, + name: 'Enterprise', + priceMonthlyVND: BigInt(4_990_000), + priceYearlyVND: BigInt(49_900_000), + maxListings: null, + maxSavedSearches: null, + features: { + basicSearch: true, + listingPost: true, + maxPhotos: 100, + analytics: true, + prioritySupport: true, + aiValuation: true, + featuredListing: true, + leadManagement: true, + agentProfile: true, + marketReports: true, + priceAlerts: true, + portfolioTracking: true, + apiAccess: true, + whiteLabel: true, + dedicatedSupport: true, + }, + }, +]; + +// ============================================================================= +// Sample coordinates for HCM districts +// ============================================================================= + +const SAMPLE_LOCATIONS: Record = { + 'Quận 1': { lat: 10.7769, lng: 106.7009 }, + 'Quận 3': { lat: 10.7834, lng: 106.6867 }, + 'Quận 7': { lat: 10.734, lng: 106.7218 }, + 'Thủ Đức': { lat: 10.8544, lng: 106.7536 }, + 'Quận Bình Thạnh': { lat: 10.8065, lng: 106.7098 }, + 'Quận Phú Nhuận': { lat: 10.7993, lng: 106.6815 }, + 'Quận Tân Bình': { lat: 10.8016, lng: 106.6525 }, + 'Quận Gò Vấp': { lat: 10.8384, lng: 106.6652 }, +}; + +// ============================================================================= +// Seed functions +// ============================================================================= + +async function seedPlans() { + console.log('Seeding subscription plans...'); + for (const plan of PLANS) { + await prisma.plan.upsert({ + where: { tier: plan.tier }, + update: { + name: plan.name, + priceMonthlyVND: plan.priceMonthlyVND, + priceYearlyVND: plan.priceYearlyVND, + maxListings: plan.maxListings, + maxSavedSearches: plan.maxSavedSearches, + features: plan.features, + }, + create: plan, + }); + } + console.log(` ✓ ${PLANS.length} plans seeded`); +} + +async function seedUsers() { + console.log('Seeding sample users...'); + + const admin = await prisma.user.upsert({ + where: { phone: '0900000001' }, + update: {}, + create: { + phone: '0900000001', + email: 'admin@goodgo.vn', + fullName: 'Admin GoodGo', + role: UserRole.ADMIN, + kycStatus: 'VERIFIED', + isActive: true, + }, + }); + + const agent1 = await prisma.user.upsert({ + where: { phone: '0900000002' }, + update: {}, + create: { + phone: '0900000002', + email: 'agent.nguyen@goodgo.vn', + fullName: 'Nguyễn Văn An', + role: UserRole.AGENT, + kycStatus: 'VERIFIED', + isActive: true, + }, + }); + + const agent2 = await prisma.user.upsert({ + where: { phone: '0900000003' }, + update: {}, + create: { + phone: '0900000003', + email: 'agent.tran@goodgo.vn', + fullName: 'Trần Thị Bình', + role: UserRole.AGENT, + kycStatus: 'VERIFIED', + isActive: true, + }, + }); + + const buyer = await prisma.user.upsert({ + where: { phone: '0900000004' }, + update: {}, + create: { + phone: '0900000004', + email: 'buyer.le@gmail.com', + fullName: 'Lê Minh Cường', + role: UserRole.BUYER, + kycStatus: 'NONE', + isActive: true, + }, + }); + + const seller = await prisma.user.upsert({ + where: { phone: '0900000005' }, + update: {}, + create: { + phone: '0900000005', + email: 'seller.pham@gmail.com', + fullName: 'Phạm Đức Dũng', + role: UserRole.SELLER, + kycStatus: 'VERIFIED', + isActive: true, + }, + }); + + // Create agent profiles + await prisma.agent.upsert({ + where: { userId: agent1.id }, + update: {}, + create: { + userId: agent1.id, + licenseNumber: 'BDS-2024-001', + agency: 'GoodGo Premium Realty', + qualityScore: 4.8, + totalDeals: 127, + responseTimeAvg: 15, + bio: 'Chuyên viên bất động sản cao cấp Quận 1, Quận 7 với 10 năm kinh nghiệm.', + serviceAreas: ['quan-1', 'quan-7', 'thu-duc'], + isVerified: true, + }, + }); + + await prisma.agent.upsert({ + where: { userId: agent2.id }, + update: {}, + create: { + userId: agent2.id, + licenseNumber: 'BDS-2024-002', + agency: 'Saigon Homes', + qualityScore: 4.5, + totalDeals: 89, + responseTimeAvg: 20, + bio: 'Chuyên gia bất động sản Bình Thạnh, Phú Nhuận. Tư vấn miễn phí.', + serviceAreas: ['binh-thanh', 'phu-nhuan', 'go-vap'], + isVerified: true, + }, + }); + + console.log(' ✓ 5 users + 2 agent profiles seeded'); + return { admin, agent1, agent2, buyer, seller }; +} + +async function seedProperties(users: Awaited>) { + console.log('Seeding sample properties and listings...'); + + const sampleProperties = [ + { + propertyType: PropertyType.APARTMENT, + title: 'Căn hộ Vinhomes Central Park 3PN view sông', + description: + 'Căn hộ 3 phòng ngủ tại Vinhomes Central Park, tầng cao view sông Sài Gòn. Full nội thất cao cấp, tiện ích đầy đủ.', + address: '208 Nguyễn Hữu Cảnh', + ward: 'Phường 22', + district: 'Quận Bình Thạnh', + city: 'Hồ Chí Minh', + lat: 10.7942, + lng: 106.7214, + areaM2: 108, + usableAreaM2: 95, + bedrooms: 3, + bathrooms: 2, + floor: 25, + totalFloors: 50, + direction: Direction.SOUTHEAST, + yearBuilt: 2018, + legalStatus: 'Sổ hồng', + priceVND: BigInt(8_500_000_000), + transactionType: TransactionType.SALE, + projectName: 'Vinhomes Central Park', + }, + { + propertyType: PropertyType.APARTMENT, + title: 'Căn hộ The Sun Avenue 2PN cho thuê', + description: 'Cho thuê căn hộ 2PN The Sun Avenue, nội thất đầy đủ, gần Metro, view đẹp.', + address: '28 Mai Chí Thọ', + ward: 'An Phú', + district: 'Thủ Đức', + city: 'Hồ Chí Minh', + lat: 10.7696, + lng: 106.7511, + areaM2: 76, + usableAreaM2: 68, + bedrooms: 2, + bathrooms: 2, + floor: 15, + totalFloors: 28, + direction: Direction.NORTH, + yearBuilt: 2020, + legalStatus: 'Sổ hồng', + priceVND: BigInt(15_000_000), + transactionType: TransactionType.RENT, + projectName: 'The Sun Avenue', + }, + { + propertyType: PropertyType.TOWNHOUSE, + title: 'Nhà phố Thảo Điền 1 trệt 3 lầu', + description: + 'Nhà phố khu compound an ninh Thảo Điền, 1 trệt 3 lầu, sân vườn rộng, gara ô tô.', + address: '12 Nguyễn Văn Hưởng', + ward: 'Thảo Điền', + district: 'Thủ Đức', + city: 'Hồ Chí Minh', + lat: 10.8033, + lng: 106.7391, + areaM2: 200, + usableAreaM2: 350, + bedrooms: 4, + bathrooms: 5, + floors: 4, + direction: Direction.SOUTH, + yearBuilt: 2015, + legalStatus: 'Sổ hồng', + priceVND: BigInt(25_000_000_000), + transactionType: TransactionType.SALE, + projectName: null, + }, + { + propertyType: PropertyType.LAND, + title: 'Đất nền Quận 7 gần Phú Mỹ Hưng', + description: 'Đất nền thổ cư 100%, sổ riêng, hẻm ô tô, gần trung tâm Phú Mỹ Hưng.', + address: '56 Huỳnh Tấn Phát', + ward: 'Phú Thuận', + district: 'Quận 7', + city: 'Hồ Chí Minh', + lat: 10.7312, + lng: 106.7283, + areaM2: 120, + usableAreaM2: null, + bedrooms: null, + bathrooms: null, + direction: Direction.EAST, + yearBuilt: null, + legalStatus: 'Sổ đỏ', + priceVND: BigInt(12_000_000_000), + transactionType: TransactionType.SALE, + projectName: null, + }, + { + propertyType: PropertyType.OFFICE, + title: 'Văn phòng cho thuê Quận 1 - 200m²', + description: + 'Văn phòng hạng B tại trung tâm Quận 1, full nội thất, hệ thống PCCC, thang máy.', + address: '123 Nguyễn Huệ', + ward: 'Bến Nghé', + district: 'Quận 1', + city: 'Hồ Chí Minh', + lat: 10.7731, + lng: 106.703, + areaM2: 200, + usableAreaM2: 180, + bedrooms: null, + bathrooms: 2, + floor: 8, + totalFloors: 15, + direction: Direction.WEST, + yearBuilt: 2010, + legalStatus: 'Sổ hồng', + priceVND: BigInt(80_000_000), + transactionType: TransactionType.RENT, + projectName: null, + }, + ]; + + const agents = await prisma.agent.findMany(); + + for (let i = 0; i < sampleProperties.length; i++) { + const p = sampleProperties[i]; + const agent = agents[i % agents.length]; + + const property = await prisma.$executeRaw` + INSERT INTO "Property" ( + "id", "propertyType", "title", "description", "address", + "ward", "district", "city", "location", + "areaM2", "usableAreaM2", "bedrooms", "bathrooms", + "floors", "floor", "totalFloors", "direction", + "yearBuilt", "legalStatus", "amenities", "nearbyPOIs", + "metroDistanceM", "projectName", "createdAt", "updatedAt" + ) VALUES ( + ${`prop-${i + 1}`}, ${p.propertyType}::"PropertyType", ${p.title}, ${p.description}, ${p.address}, + ${p.ward}, ${p.district}, ${p.city}, ST_SetSRID(ST_MakePoint(${p.lng}, ${p.lat}), 4326), + ${p.areaM2}, ${p.usableAreaM2 ?? null}, ${p.bedrooms ?? null}, ${p.bathrooms ?? null}, + ${p.floors ?? null}, ${p.floor ?? null}, ${p.totalFloors ?? null}, ${p.direction ?? null}::"Direction", + ${p.yearBuilt ?? null}, ${p.legalStatus ?? null}, ${null}, ${null}, + ${null}, ${p.projectName ?? null}, NOW(), NOW() + ) + ON CONFLICT ("id") DO NOTHING + `; + + await prisma.listing.upsert({ + where: { id: `listing-${i + 1}` }, + update: {}, + create: { + id: `listing-${i + 1}`, + propertyId: `prop-${i + 1}`, + agentId: agent.id, + sellerId: users.seller.id, + transactionType: p.transactionType, + status: ListingStatus.ACTIVE, + priceVND: p.priceVND, + pricePerM2: Number(p.priceVND) / p.areaM2, + publishedAt: new Date(), + }, + }); + } + + console.log(` ✓ ${sampleProperties.length} properties + listings seeded`); +} + +async function seedMarketIndex() { + console.log('Seeding sample market index data...'); + + const districts = ['Quận 1', 'Quận 3', 'Quận 7', 'Thủ Đức', 'Quận Bình Thạnh']; + const periods = ['2025-Q4', '2026-Q1']; + + for (const district of districts) { + for (const period of periods) { + for (const propertyType of [PropertyType.APARTMENT, PropertyType.TOWNHOUSE]) { + const basePrice = + district === 'Quận 1' ? 120_000_000 : district === 'Quận 7' ? 65_000_000 : 55_000_000; + const randomFactor = 0.9 + Math.random() * 0.2; + + await prisma.marketIndex.upsert({ + where: { + district_city_propertyType_period: { + district, + city: 'Hồ Chí Minh', + propertyType, + period, + }, + }, + update: {}, + create: { + district, + city: 'Hồ Chí Minh', + propertyType, + period, + medianPrice: BigInt(Math.round(basePrice * randomFactor * 80)), + avgPriceM2: basePrice * randomFactor, + totalListings: Math.floor(100 + Math.random() * 500), + daysOnMarket: 30 + Math.random() * 60, + inventoryLevel: Math.floor(50 + Math.random() * 200), + absorptionRate: 0.3 + Math.random() * 0.4, + yoyChange: -5 + Math.random() * 15, + }, + }); + } + } + } + + console.log(' ✓ Market index data seeded'); +} + +// ============================================================================= +// Main seed +// ============================================================================= + +async function main() { + console.log('🌱 Starting seed...\n'); + + await seedPlans(); + const users = await seedUsers(); + await seedProperties(users); + await seedMarketIndex(); + + console.log('\n✅ Seed completed successfully!'); +} + +main() + .catch((e) => { + console.error('❌ Seed failed:', e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + });