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>
341 lines
11 KiB
TypeScript
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);
|
|
});
|