Files
STR_Optimization_Manager/apps/api/src/db/seed.ts
olsch01 d4c714fadc Initial commit: STR Optimization Manager MVP
Full-stack short-term rental management platform with:
- React/Vite frontend with dark theme dashboard, performance, pricing,
  reservations, experiments, and settings pages
- Fastify API server with auth, platform management, performance tracking,
  pricing, reservations, experiments, and weekly report endpoints
- Playwright-based scraper service with Airbnb adapter (login with MFA,
  performance metrics, reservations, calendar pricing, price changes)
- VRBO adapter scaffold and mock adapter for development
- PostgreSQL with Drizzle ORM, migrations, and seed scripts
- Job queue with worker for async scraping tasks
- AES-256-GCM credential encryption for platform credentials
- Session cookie persistence for scraper browser sessions
- Docker Compose for PostgreSQL database

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-23 15:04:12 -04:00

341 lines
11 KiB
TypeScript

import dotenv from 'dotenv';
import { fileURLToPath } from 'url';
import { dirname, resolve } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
dotenv.config({ path: resolve(__dirname, '../../../../.env') });
import { db, sql } from './index.js';
import {
platforms,
performanceSnapshots,
dailyPrices,
priceChanges,
experiments,
reservations,
scrapeJobs,
} from './schema.js';
// ── Helpers ─────────────────────────────────────────────────────────────────
function rand(min: number, max: number): number {
return Math.random() * (max - min) + min;
}
function randInt(min: number, max: number): number {
return Math.floor(rand(min, max + 1));
}
function roundTo(n: number, decimals: number): string {
return n.toFixed(decimals);
}
function daysAgo(n: number): Date {
const d = new Date();
d.setDate(d.getDate() - n);
d.setHours(0, 0, 0, 0);
return d;
}
function formatDate(d: Date): string {
return d.toISOString().split('T')[0];
}
function isWeekend(d: Date): boolean {
const day = d.getDay();
return day === 0 || day === 5 || day === 6; // Fri, Sat, Sun
}
const GUEST_NAMES = [
'Sarah Johnson',
'Michael Chen',
'Emily Rodriguez',
'James Wilson',
'Amanda Foster',
'David Kim',
'Jessica Taylor',
'Robert Martinez',
'Lisa Anderson',
'Chris Thompson',
'Rachel Lee',
'Daniel Brown',
'Maria Garcia',
'Kevin Wright',
'Stephanie Davis',
'Andrew Moore',
'Nicole Clark',
'Brandon Hall',
];
// ── Seed Logic ──────────────────────────────────────────────────────────────
async function seed() {
console.log('Clearing existing data...');
// Delete in FK-safe order
await db.delete(priceChanges);
await db.delete(scrapeJobs);
await db.delete(reservations);
await db.delete(dailyPrices);
await db.delete(performanceSnapshots);
await db.delete(experiments);
await db.delete(platforms);
console.log('Inserting platforms...');
await db.insert(platforms).values([
{ id: 'airbnb', displayName: 'Airbnb' },
{ id: 'vrbo', displayName: 'VRBO' },
{ id: 'mock', displayName: 'Mock (Dev)' },
]);
// ── Experiments ───────────────────────────────────────────────────────────
console.log('Inserting experiments...');
const experimentRows = [
{
name: 'Weekend Premium Pricing',
hypothesis:
'Increasing weekend rates by 15% will maintain occupancy while boosting revenue.',
startDate: formatDate(daysAgo(60)),
endDate: formatDate(daysAgo(30)),
status: 'completed',
conclusion:
'Weekend premium of 15% maintained 92% of baseline bookings while increasing weekend revenue by 11%.',
},
{
name: 'Minimum Stay Reduction',
hypothesis:
'Reducing minimum stay from 3 nights to 2 nights on weekdays will fill gap nights and increase occupancy.',
startDate: formatDate(daysAgo(30)),
endDate: formatDate(daysAgo(7)),
status: 'completed',
conclusion:
'Occupancy rose 8% during the test period. Gap nights filled at an acceptable rate. Recommend keeping 2-night min on weekdays.',
},
{
name: 'Dynamic Last-Minute Discount',
hypothesis:
'Offering a 10% discount for bookings within 3 days of check-in will reduce vacancy on otherwise-empty nights.',
startDate: formatDate(daysAgo(14)),
status: 'active',
},
];
const insertedExperiments = await db
.insert(experiments)
.values(experimentRows)
.returning({ id: experiments.id });
// ── Performance Snapshots ─────────────────────────────────────────────────
console.log('Inserting performance snapshots...');
const snapshotRows: (typeof performanceSnapshots.$inferInsert)[] = [];
for (const platformId of ['airbnb', 'vrbo']) {
const platformMultiplier = platformId === 'airbnb' ? 1.0 : 0.6;
for (let daysBack = 89; daysBack >= 0; daysBack--) {
const d = daysAgo(daysBack);
// Slight upward trend: later days get a small boost
const trendFactor = 1 + (90 - daysBack) * 0.001;
const viewsSearch = Math.round(
randInt(500, 2000) * platformMultiplier * trendFactor,
);
const viewsListing = Math.round(
randInt(100, 500) * platformMultiplier * trendFactor,
);
const occupancy = Math.min(
rand(55, 85) * trendFactor,
98,
);
const adr = rand(150, 250) * trendFactor;
const bookingsCount = Math.round(
randInt(2, 8) * platformMultiplier * (occupancy / 70),
);
const conversionRate = (bookingsCount / viewsListing) * 100;
const revenue = adr * occupancy * 0.3; // rough approximation
snapshotRows.push({
platformId,
capturedAt: d.toISOString(),
periodLabel: formatDate(d),
viewsSearch,
viewsListing,
conversionRate: roundTo(conversionRate, 2),
bookingsCount,
occupancyRate: roundTo(occupancy, 1),
avgDailyRate: roundTo(adr, 2),
revenueTotal: roundTo(revenue, 2),
rawJson: { source: 'seed', generated: true },
});
}
}
// Insert in batches
for (let i = 0; i < snapshotRows.length; i += 50) {
await db
.insert(performanceSnapshots)
.values(snapshotRows.slice(i, i + 50));
}
// ── Daily Prices ──────────────────────────────────────────────────────────
console.log('Inserting daily prices...');
const priceRows: (typeof dailyPrices.$inferInsert)[] = [];
for (const platformId of ['airbnb', 'vrbo']) {
for (let daysBack = 89; daysBack >= -30; daysBack--) {
// Include 30 days into the future
const d = daysAgo(daysBack);
const trendFactor = 1 + (90 - daysBack) * 0.0005;
let basePrice = rand(150, 250) * trendFactor;
if (isWeekend(d)) {
basePrice *= rand(1.15, 1.35); // Weekend premium
}
priceRows.push({
platformId,
date: formatDate(d),
price: roundTo(basePrice, 2),
isAvailable: Math.random() > 0.15, // ~85% availability
minStayNights: isWeekend(d) ? 2 : 1,
syncedAt: daysAgo(Math.max(daysBack, 0)).toISOString(),
});
}
}
for (let i = 0; i < priceRows.length; i += 50) {
await db.insert(dailyPrices).values(priceRows.slice(i, i + 50));
}
// ── Reservations ──────────────────────────────────────────────────────────
console.log('Inserting reservations...');
const reservationRows: (typeof reservations.$inferInsert)[] = [];
let reservationCounter = 1000;
for (let i = 0; i < 15; i++) {
const platformId = i % 3 === 0 ? 'vrbo' : 'airbnb';
const checkInDaysAgo = randInt(5, 85);
const nights = randInt(2, 7);
const checkIn = daysAgo(checkInDaysAgo);
const checkOut = daysAgo(checkInDaysAgo - nights);
const nightlyRate = rand(160, 280);
const cleaningFee = randInt(75, 150);
const platformFee = nightlyRate * nights * rand(0.03, 0.05);
const totalPayout =
nightlyRate * nights + cleaningFee - platformFee;
const bookedDaysBeforeCheckIn = randInt(3, 30);
const bookedAt = daysAgo(checkInDaysAgo + bookedDaysBeforeCheckIn);
reservationCounter++;
const isPast = checkInDaysAgo - nights > 0;
reservationRows.push({
platformId,
platformReservationId: `${platformId.toUpperCase()}-${reservationCounter}`,
guestName: GUEST_NAMES[i % GUEST_NAMES.length],
checkIn: formatDate(checkIn),
checkOut: formatDate(checkOut),
nights,
guestsCount: randInt(1, 6),
nightlyRate: roundTo(nightlyRate, 2),
cleaningFee: cleaningFee.toString(),
platformFee: roundTo(platformFee, 2),
totalPayout: roundTo(totalPayout, 2),
status: isPast ? 'completed' : 'confirmed',
bookedAt: bookedAt.toISOString(),
rawJson: { source: 'seed', generated: true },
});
}
await db.insert(reservations).values(reservationRows);
// ── Price Changes ─────────────────────────────────────────────────────────
console.log('Inserting price changes...');
const priceChangeRows: (typeof priceChanges.$inferInsert)[] = [];
// Link some price changes to the first experiment (weekend premium)
for (let i = 0; i < 8; i++) {
const d = daysAgo(randInt(30, 60));
const before = rand(180, 230);
const after = before * 1.15;
priceChangeRows.push({
platformId: i % 2 === 0 ? 'airbnb' : 'vrbo',
date: formatDate(d),
priceBefore: roundTo(before, 2),
priceAfter: roundTo(after, 2),
changedBy: 'experiment',
note: 'Weekend premium pricing experiment',
experimentId: insertedExperiments[0].id,
});
}
// A few manual changes
for (let i = 0; i < 4; i++) {
const d = daysAgo(randInt(5, 25));
const before = rand(170, 250);
const after = before * rand(0.9, 1.1);
priceChangeRows.push({
platformId: 'airbnb',
date: formatDate(d),
priceBefore: roundTo(before, 2),
priceAfter: roundTo(after, 2),
changedBy: 'manual',
note: 'Manual price adjustment',
});
}
await db.insert(priceChanges).values(priceChangeRows);
// ── Scrape Jobs ───────────────────────────────────────────────────────────
console.log('Inserting scrape jobs...');
const scrapeRows: (typeof scrapeJobs.$inferInsert)[] = [];
for (const platformId of ['airbnb', 'vrbo']) {
for (let i = 0; i < 5; i++) {
const startedDaysAgo = randInt(0, 30);
const started = daysAgo(startedDaysAgo);
const completed = new Date(
started.getTime() + randInt(5, 120) * 1000,
);
scrapeRows.push({
platformId,
jobType: i % 2 === 0 ? 'performance' : 'calendar',
triggeredBy: i === 0 ? 'manual' : 'scheduled',
status: 'completed',
startedAt: started.toISOString(),
completedAt: completed.toISOString(),
rowsCollected: randInt(10, 200),
});
}
}
await db.insert(scrapeJobs).values(scrapeRows);
console.log('Seed complete.');
}
seed()
.then(() => {
process.exit(0);
})
.catch((err) => {
console.error('Seed failed:', err);
process.exit(1);
});