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>
This commit is contained in:
340
apps/api/src/db/seed.ts
Normal file
340
apps/api/src/db/seed.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user