feat(db): add initial Prisma migration and seed script

- Add init migration with all 23 models including PostGIS
- Add seed script for districts, plans, and sample data

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 02:04:30 +07:00
parent 19dd59e4eb
commit ff358f6148
3 changed files with 1257 additions and 0 deletions

View File

@@ -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;

View File

@@ -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"

786
prisma/seed.ts Normal file
View File

@@ -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<string, { lat: number; lng: number }> = {
'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<ReturnType<typeof seedUsers>>) {
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();
});