/** * Top up the dev DB with synthetic listings so the homepage's * "Top biến động giá 7 ngày" query can find ≥10 listings per district * in two adjacent 7-day windows. Uses existing properties as templates. * * Idempotent: re-running deletes the previously generated synthetic batch * (id prefix `bulk-mvr-`) before inserting fresh rows so the timestamps * stay inside the rolling window. * * Usage: * NODE_OPTIONS="-r dotenv/config" DOTENV_CONFIG_PATH=.env \ * pnpm tsx scripts/seed-bulk-listings-per-district.ts */ import { PrismaPg } from '@prisma/adapter-pg'; import { PrismaClient } from '@prisma/client'; import pg from 'pg'; const pool = new pg.Pool({ connectionString: process.env['DATABASE_URL'] }); const adapter = new PrismaPg(pool); const prisma = new PrismaClient({ adapter }); const ID_PREFIX = 'bulk-mvr-'; const PROP_PREFIX = 'bulk-prop-'; const PER_DISTRICT_PER_WINDOW = 12; interface Template { district: string; city: string; ward: string; address: string; lat: number; lng: number; basePriceVND: bigint; areaM2: number; propertyType: 'APARTMENT' | 'TOWNHOUSE' | 'LAND' | 'OFFICE' | 'VILLA' | 'SHOPHOUSE'; } const TEMPLATES: Template[] = [ { district: 'Quận 1', city: 'Hồ Chí Minh', ward: 'Bến Nghé', address: '123 Nguyễn Huệ', lat: 10.7731, lng: 106.703, basePriceVND: 25_000_000_000n, areaM2: 90, propertyType: 'APARTMENT' }, { district: 'Quận 7', city: 'Hồ Chí Minh', ward: 'Tân Phú', address: '12 Nguyễn Lương Bằng', lat: 10.7285, lng: 106.7195, basePriceVND: 8_000_000_000n, areaM2: 85, propertyType: 'APARTMENT' }, { district: 'Quận Bình Thạnh', city: 'Hồ Chí Minh', ward: 'Phường 22', address: '208 Nguyễn Hữu Cảnh', lat: 10.7942, lng: 106.7214, basePriceVND: 12_000_000_000n, areaM2: 100, propertyType: 'APARTMENT' }, { district: 'Quận Gò Vấp', city: 'Hồ Chí Minh', ward: 'Phường 17', address: '88 Nguyễn Oanh', lat: 10.8352, lng: 106.6648, basePriceVND: 6_500_000_000n, areaM2: 65, propertyType: 'TOWNHOUSE' }, { district: 'Thành phố Thủ Đức', city: 'Hồ Chí Minh', ward: 'An Phú', address: '28 Mai Chí Thọ', lat: 10.7696, lng: 106.7511, basePriceVND: 7_500_000_000n, areaM2: 76, propertyType: 'APARTMENT' }, { district: 'Quận 3', city: 'Hồ Chí Minh', ward: 'Phường 8', address: '45 Võ Thị Sáu', lat: 10.7849, lng: 106.6909, basePriceVND: 18_000_000_000n, areaM2: 80, propertyType: 'APARTMENT' }, { district: 'Quận Tân Bình', city: 'Hồ Chí Minh', ward: 'Phường 4', address: '67 Lê Văn Sỹ', lat: 10.7916, lng: 106.6747, basePriceVND: 9_500_000_000n, areaM2: 75, propertyType: 'APARTMENT' }, { district: 'Quận Phú Nhuận', city: 'Hồ Chí Minh', ward: 'Phường 7', address: '23 Phan Đình Phùng', lat: 10.7972, lng: 106.6791, basePriceVND: 11_000_000_000n, areaM2: 70, propertyType: 'APARTMENT' }, ]; async function getSellerId(): Promise { const seller = await prisma.user.findFirst({ where: { role: 'SELLER' } }); if (!seller) throw new Error('No SELLER user found — seed users first'); return seller.id; } async function clearPrevious() { await prisma.listing.deleteMany({ where: { id: { startsWith: ID_PREFIX } } }); await prisma.property.deleteMany({ where: { id: { startsWith: PROP_PREFIX } } }); } async function generate() { await clearPrevious(); const sellerId = await getSellerId(); const now = Date.now(); const day = 24 * 60 * 60 * 1000; let propCount = 0; let listingCount = 0; for (const tpl of TEMPLATES) { // 12 listings in current 7-day window + 12 in previous 7-day window for (let win = 0; win < 2; win++) { // win=0 → 0..7 days ago (current window) // win=1 → 7..14 days ago (previous window) // Current window prices drift +5% so movers detects an "up" signal const drift = win === 0 ? 1.05 : 1.0; for (let i = 0; i < PER_DISTRICT_PER_WINDOW; i++) { const dayOffset = win * 7 + Math.floor(Math.random() * 7); const createdAt = new Date(now - dayOffset * day - Math.random() * day); // ±8% price jitter around the template baseline const jitter = 0.92 + Math.random() * 0.16; const priceVND = BigInt(Math.round(Number(tpl.basePriceVND) * drift * jitter)); const areaM2 = Math.round((tpl.areaM2 + (Math.random() * 20 - 10)) * 10) / 10; const pricePerM2 = Number(priceVND) / areaM2; const districtSlug = tpl.district.toLowerCase().replace(/[^a-z0-9]+/g, '-'); const propId = `${PROP_PREFIX}${districtSlug}-w${win}-${i}`; const lat = tpl.lat + (Math.random() - 0.5) * 0.005; const lng = tpl.lng + (Math.random() - 0.5) * 0.005; await prisma.$executeRaw` INSERT INTO "Property" ( "id", "propertyType", "title", "description", "address", "ward", "district", "city", "location", "areaM2", "createdAt", "updatedAt" ) VALUES ( ${propId}, ${tpl.propertyType}::"PropertyType", ${`${tpl.propertyType} ${tpl.district} #${i + 1} (window ${win})`}, ${`Synthetic listing in ${tpl.district} for movers panel.`}, ${tpl.address}, ${tpl.ward}, ${tpl.district}, ${tpl.city}, ST_SetSRID(ST_MakePoint(${lng}, ${lat}), 4326), ${areaM2}, NOW(), NOW() ) ON CONFLICT ("id") DO NOTHING `; propCount++; await prisma.listing.create({ data: { id: `${ID_PREFIX}${districtSlug}-w${win}-${i}`, propertyId: propId, sellerId, transactionType: 'SALE', priceVND, pricePerM2, status: 'ACTIVE', createdAt, updatedAt: createdAt, publishedAt: createdAt, }, }); listingCount++; } } } console.log(`✓ Inserted ${propCount} properties + ${listingCount} listings across ${TEMPLATES.length} districts.`); } async function main() { try { await generate(); } catch (err) { console.error(err); process.exit(1); } finally { await prisma.$disconnect(); await pool.end(); } } if (require.main === module) { void main(); }