diff --git a/prisma/seed.ts b/prisma/seed.ts index ffc958b..796efa0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -1,450 +1,28 @@ import { PrismaClient, UserRole, - PlanTier, PropertyType, TransactionType, ListingStatus, Direction, } from '@prisma/client'; +import { PLANS, seedPlans } from '../scripts/seed-plans'; +import { HCM_DISTRICTS, HANOI_DISTRICTS, DANANG_DISTRICTS, CITY_COORDINATES } from '../scripts/seed-districts'; +import { importMarketData } from '../scripts/import-market-data'; 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 }, -}; +const SAMPLE_LOCATIONS = CITY_COORDINATES['Hồ Chí Minh']; // ============================================================================= // 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`); -} +// seedPlans is imported from scripts/seed-plans.ts async function seedUsers() { console.log('Seeding sample users...'); @@ -717,49 +295,7 @@ async function seedProperties(users: Awaited>) { 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'); -} +// seedMarketIndex is now handled by importMarketData from scripts/import-market-data.ts // ============================================================================= // Main seed @@ -771,7 +307,7 @@ async function main() { await seedPlans(); const users = await seedUsers(); await seedProperties(users); - await seedMarketIndex(); + await importMarketData(); console.log('\n✅ Seed completed successfully!'); } diff --git a/scripts/import-market-data.ts b/scripts/import-market-data.ts new file mode 100644 index 0000000..6709668 --- /dev/null +++ b/scripts/import-market-data.ts @@ -0,0 +1,283 @@ +/** + * Import sample market data for development. + * + * Generates MarketIndex records across HCM, Hanoi, and Da Nang + * with realistic pricing for Vietnamese real estate. + * + * Usage: npx tsx scripts/import-market-data.ts + * Idempotent: uses upsert on compound unique constraint. + */ + +import { PrismaClient, PropertyType } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// ============================================================================= +// Market data configuration — avg price per m2 (VND) by city/district +// ============================================================================= + +interface DistrictPricing { + district: string; + avgPriceM2: Record; // keyed by PropertyType +} + +const MARKET_DATA: Record = { + 'Hồ Chí Minh': [ + { + district: 'Quận 1', + avgPriceM2: { + APARTMENT: 120_000_000, + TOWNHOUSE: 250_000_000, + OFFICE: 80_000_000, + SHOPHOUSE: 300_000_000, + }, + }, + { + district: 'Quận 3', + avgPriceM2: { + APARTMENT: 90_000_000, + TOWNHOUSE: 180_000_000, + OFFICE: 60_000_000, + }, + }, + { + district: 'Quận 7', + avgPriceM2: { + APARTMENT: 65_000_000, + TOWNHOUSE: 130_000_000, + LAND: 85_000_000, + }, + }, + { + district: 'Thủ Đức', + avgPriceM2: { + APARTMENT: 55_000_000, + TOWNHOUSE: 100_000_000, + LAND: 60_000_000, + VILLA: 120_000_000, + }, + }, + { + district: 'Quận Bình Thạnh', + avgPriceM2: { + APARTMENT: 70_000_000, + TOWNHOUSE: 140_000_000, + }, + }, + { + district: 'Quận Phú Nhuận', + avgPriceM2: { + APARTMENT: 80_000_000, + TOWNHOUSE: 160_000_000, + }, + }, + { + district: 'Quận Tân Bình', + avgPriceM2: { + APARTMENT: 50_000_000, + TOWNHOUSE: 110_000_000, + LAND: 55_000_000, + }, + }, + { + district: 'Quận Gò Vấp', + avgPriceM2: { + APARTMENT: 45_000_000, + TOWNHOUSE: 90_000_000, + LAND: 50_000_000, + }, + }, + ], + 'Hà Nội': [ + { + district: 'Hoàn Kiếm', + avgPriceM2: { + APARTMENT: 110_000_000, + TOWNHOUSE: 350_000_000, + SHOPHOUSE: 400_000_000, + }, + }, + { + district: 'Ba Đình', + avgPriceM2: { + APARTMENT: 95_000_000, + TOWNHOUSE: 220_000_000, + VILLA: 200_000_000, + }, + }, + { + district: 'Đống Đa', + avgPriceM2: { + APARTMENT: 75_000_000, + TOWNHOUSE: 180_000_000, + }, + }, + { + district: 'Hai Bà Trưng', + avgPriceM2: { + APARTMENT: 70_000_000, + TOWNHOUSE: 170_000_000, + }, + }, + { + district: 'Cầu Giấy', + avgPriceM2: { + APARTMENT: 65_000_000, + TOWNHOUSE: 150_000_000, + OFFICE: 55_000_000, + }, + }, + { + district: 'Tây Hồ', + avgPriceM2: { + APARTMENT: 80_000_000, + VILLA: 180_000_000, + TOWNHOUSE: 160_000_000, + }, + }, + { + district: 'Nam Từ Liêm', + avgPriceM2: { + APARTMENT: 55_000_000, + TOWNHOUSE: 120_000_000, + LAND: 65_000_000, + }, + }, + ], + 'Đà Nẵng': [ + { + district: 'Hải Châu', + avgPriceM2: { + APARTMENT: 45_000_000, + TOWNHOUSE: 100_000_000, + SHOPHOUSE: 120_000_000, + }, + }, + { + district: 'Thanh Khê', + avgPriceM2: { + APARTMENT: 35_000_000, + TOWNHOUSE: 70_000_000, + LAND: 40_000_000, + }, + }, + { + district: 'Sơn Trà', + avgPriceM2: { + APARTMENT: 40_000_000, + TOWNHOUSE: 80_000_000, + VILLA: 95_000_000, + }, + }, + { + district: 'Ngũ Hành Sơn', + avgPriceM2: { + APARTMENT: 42_000_000, + VILLA: 110_000_000, + LAND: 55_000_000, + }, + }, + { + district: 'Liên Chiểu', + avgPriceM2: { + APARTMENT: 28_000_000, + TOWNHOUSE: 55_000_000, + LAND: 30_000_000, + }, + }, + { + district: 'Cẩm Lệ', + avgPriceM2: { + APARTMENT: 30_000_000, + TOWNHOUSE: 60_000_000, + LAND: 35_000_000, + }, + }, + ], +}; + +const PERIODS = ['2025-Q3', '2025-Q4', '2026-Q1']; + +function randomVariation(base: number, pct: number): number { + return base * (1 + (Math.random() - 0.5) * 2 * pct); +} + +async function importMarketData() { + console.log('Importing market data for HCM, Hanoi, Da Nang...\n'); + + let total = 0; + + for (const [city, districts] of Object.entries(MARKET_DATA)) { + console.log(` ${city}:`); + let cityCount = 0; + + for (const { district, avgPriceM2 } of districts) { + for (const [propTypeKey, basePrice] of Object.entries(avgPriceM2)) { + const propertyType = propTypeKey as PropertyType; + + for (const period of PERIODS) { + const variation = randomVariation(basePrice, 0.08); + const medianArea = propertyType === 'LAND' ? 100 : propertyType === 'VILLA' ? 200 : 75; + const totalListings = Math.floor(50 + Math.random() * 400); + const daysOnMarket = 20 + Math.random() * 70; + + // YoY change: slight uptrend with noise + const periodIdx = PERIODS.indexOf(period); + const trendPct = (periodIdx - 1) * 2; // negative for older, positive for newer + const yoyChange = trendPct + (Math.random() - 0.5) * 10; + + await prisma.marketIndex.upsert({ + where: { + district_city_propertyType_period: { + district, + city, + propertyType, + period, + }, + }, + update: { + avgPriceM2: variation, + medianPrice: BigInt(Math.round(variation * medianArea)), + totalListings, + daysOnMarket, + inventoryLevel: Math.floor(totalListings * 0.6), + absorptionRate: 0.2 + Math.random() * 0.5, + yoyChange, + }, + create: { + district, + city, + propertyType, + period, + avgPriceM2: variation, + medianPrice: BigInt(Math.round(variation * medianArea)), + totalListings, + daysOnMarket, + inventoryLevel: Math.floor(totalListings * 0.6), + absorptionRate: 0.2 + Math.random() * 0.5, + yoyChange, + }, + }); + + cityCount++; + total++; + } + } + } + + console.log(` ${cityCount} market index records`); + } + + console.log(`\nTotal: ${total} market index records imported.`); +} + +if (require.main === module) { + importMarketData() + .catch((e) => { + console.error('Import market data failed:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} + +export { importMarketData, MARKET_DATA, PERIODS }; diff --git a/scripts/seed-districts.ts b/scripts/seed-districts.ts new file mode 100644 index 0000000..8166014 --- /dev/null +++ b/scripts/seed-districts.ts @@ -0,0 +1,323 @@ +/** + * Seed Vietnam district/ward data for development. + * + * Creates sample properties across major cities (HCM, Hanoi, Da Nang) + * to ensure district coverage in the dev database. + * + * Usage: npx tsx scripts/seed-districts.ts + * Idempotent: safe to run multiple times. + */ + +import { PrismaClient, PropertyType, Direction } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// ============================================================================= +// District & Ward data — canonical source +// ============================================================================= + +export interface DistrictData { + district: string; + wards: string[]; +} + +export const HCM_DISTRICTS: DistrictData[] = [ + { + 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', + ], + }, +]; + +export const HANOI_DISTRICTS: DistrictData[] = [ + { + 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', + ], + }, +]; + +export const DANANG_DISTRICTS: DistrictData[] = [ + { + district: 'Hải Châu', + wards: [ + 'Hải Châu 1', 'Hải Châu 2', 'Thạch Thang', 'Thanh Bình', 'Thuận Phước', + 'Hòa Thuận Tây', 'Hòa Thuận Đông', 'Nam Dương', 'Phước Ninh', + 'Bình Hiên', 'Bình Thuận', 'Hòa Cường Bắc', 'Hòa Cường Nam', + ], + }, + { + district: 'Thanh Khê', + wards: [ + 'Tam Thuận', 'Thanh Khê Đông', 'Thanh Khê Tây', 'Xuân Hà', 'Tân Chính', + 'Chính Gián', 'Vĩnh Trung', 'Thạc Gián', 'An Khê', 'Hòa Khê', + ], + }, + { + district: 'Sơn Trà', + wards: [ + 'An Hải Bắc', 'An Hải Đông', 'An Hải Tây', 'Mân Thái', 'Nại Hiên Đông', + 'Phước Mỹ', 'Thọ Quang', + ], + }, + { + district: 'Ngũ Hành Sơn', + wards: [ + 'Hòa Hải', 'Hòa Quý', 'Khuê Mỹ', 'Mỹ An', + ], + }, + { + district: 'Liên Chiểu', + wards: [ + 'Hòa Hiệp Bắc', 'Hòa Hiệp Nam', 'Hòa Khánh Bắc', 'Hòa Khánh Nam', + 'Hòa Minh', + ], + }, + { + district: 'Cẩm Lệ', + wards: [ + 'Hòa An', 'Hòa Phát', 'Hòa Thọ Đông', 'Hòa Thọ Tây', 'Hòa Xuân', + 'Khuê Trung', + ], + }, +]; + +// Coordinates for property generation +export const CITY_COORDINATES: Record> = { + 'Hồ Chí Minh': { + '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 }, + }, + 'Hà Nội': { + 'Hoàn Kiếm': { lat: 21.0285, lng: 105.8542 }, + 'Ba Đình': { lat: 21.0355, lng: 105.8193 }, + 'Đống Đa': { lat: 21.0155, lng: 105.8282 }, + 'Hai Bà Trưng': { lat: 21.0064, lng: 105.8594 }, + 'Cầu Giấy': { lat: 21.0313, lng: 105.7977 }, + 'Tây Hồ': { lat: 21.0645, lng: 105.8237 }, + 'Nam Từ Liêm': { lat: 21.0175, lng: 105.7588 }, + }, + 'Đà Nẵng': { + 'Hải Châu': { lat: 16.0544, lng: 108.2022 }, + 'Thanh Khê': { lat: 16.0674, lng: 108.1811 }, + 'Sơn Trà': { lat: 16.0894, lng: 108.2331 }, + 'Ngũ Hành Sơn': { lat: 16.0211, lng: 108.2462 }, + 'Liên Chiểu': { lat: 16.0777, lng: 108.1478 }, + 'Cẩm Lệ': { lat: 16.0186, lng: 108.2012 }, + }, +}; + +export function getAllDistricts(): { city: string; districts: DistrictData[] }[] { + return [ + { city: 'Hồ Chí Minh', districts: HCM_DISTRICTS }, + { city: 'Hà Nội', districts: HANOI_DISTRICTS }, + { city: 'Đà Nẵng', districts: DANANG_DISTRICTS }, + ]; +} + +// ============================================================================= +// Seed: create sample properties across all districts +// ============================================================================= + +const PROPERTY_TEMPLATES = [ + { type: PropertyType.APARTMENT, titleFn: (d: string) => `Căn hộ cao cấp ${d}`, area: 75, beds: 2, baths: 2, dir: Direction.SOUTHEAST }, + { type: PropertyType.TOWNHOUSE, titleFn: (d: string) => `Nhà phố ${d} 1 trệt 2 lầu`, area: 120, beds: 3, baths: 3, dir: Direction.SOUTH }, + { type: PropertyType.LAND, titleFn: (d: string) => `Đất nền thổ cư ${d}`, area: 100, beds: null, baths: null, dir: Direction.EAST }, +]; + +async function seedDistrictProperties() { + console.log('Seeding district properties across HCM, Hanoi, Da Nang...\n'); + + let created = 0; + let skipped = 0; + + for (const { city, districts } of getAllDistricts()) { + console.log(` ${city}:`); + const coords = CITY_COORDINATES[city] ?? {}; + + for (const { district, wards } of districts) { + const ward = wards[0]; + const template = PROPERTY_TEMPLATES[created % PROPERTY_TEMPLATES.length]; + const loc = coords[district] ?? { lat: 10.0, lng: 106.0 }; + // Small random offset so each property has unique coords + const jitterLat = (Math.random() - 0.5) * 0.005; + const jitterLng = (Math.random() - 0.5) * 0.005; + + const propId = `dist-prop-${city.substring(0, 3).toLowerCase()}-${district.substring(0, 10).toLowerCase().replace(/\s/g, '-')}`; + + try { + await prisma.$executeRaw` + INSERT INTO "Property" ( + "id", "propertyType", "title", "description", "address", + "ward", "district", "city", "location", + "areaM2", "bedrooms", "bathrooms", "direction", + "legalStatus", "createdAt", "updatedAt" + ) VALUES ( + ${propId}, ${template.type}::"PropertyType", + ${template.titleFn(district)}, + ${'Bất động sản mẫu dùng cho phát triển. ' + district + ', ' + city + '.'}, + ${'Số 1 Đường chính'}, + ${ward}, ${district}, ${city}, + ST_SetSRID(ST_MakePoint(${loc.lng + jitterLng}, ${loc.lat + jitterLat}), 4326), + ${template.area}, ${template.beds}, ${template.baths}, + ${template.dir}::"Direction", + ${'Sổ hồng'}, NOW(), NOW() + ) + ON CONFLICT ("id") DO NOTHING + `; + created++; + } catch { + skipped++; + } + } + + const cityDistricts = districts.length; + console.log(` ${cityDistricts} districts processed`); + } + + console.log(`\n Total: ${created} properties created, ${skipped} skipped (already exist)`); +} + +async function main() { + console.log('=== Seed Districts — Vietnam Real Estate Dev Data ===\n'); + + // Log summary + for (const { city, districts } of getAllDistricts()) { + const totalWards = districts.reduce((sum, d) => sum + d.wards.length, 0); + console.log(` ${city}: ${districts.length} districts, ${totalWards} wards`); + } + console.log(''); + + await seedDistrictProperties(); + console.log('\nDone.'); +} + +// Run standalone or import as module +if (require.main === module) { + main() + .catch((e) => { + console.error('Seed districts failed:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} diff --git a/scripts/seed-plans.ts b/scripts/seed-plans.ts new file mode 100644 index 0000000..6b46a39 --- /dev/null +++ b/scripts/seed-plans.ts @@ -0,0 +1,129 @@ +/** + * Seed subscription plans (FREE, AGENT_PRO, INVESTOR, ENTERPRISE). + * + * Usage: npx tsx scripts/seed-plans.ts + * Idempotent: uses upsert on PlanTier unique constraint. + */ + +import { PrismaClient, PlanTier } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export 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, + }, + }, +]; + +async function seedPlans() { + console.log('Seeding subscription plans...\n'); + + for (const plan of PLANS) { + const result = 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, + }); + + const monthly = Number(plan.priceMonthlyVND).toLocaleString('vi-VN'); + console.log(` ${plan.tier.padEnd(12)} ${plan.name.padEnd(14)} ${monthly} VND/tháng`); + } + + console.log(`\n${PLANS.length} plans seeded.`); +} + +if (require.main === module) { + seedPlans() + .catch((e) => { + console.error('Seed plans failed:', e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); +} + +export { seedPlans };