import { db } from './db';
import bcrypt from 'bcryptjs';
export async function initSchema() {
await db.query(`
CREATE TABLE IF NOT EXISTS users (
id SERIAL PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
comments_disabled BOOLEAN DEFAULT FALSE,
first_name VARCHAR(100),
last_name VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS images (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
data TEXT NOT NULL,
thumbnail TEXT,
medium TEXT,
category VARCHAR(100) DEFAULT 'other',
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
location_target VARCHAR(100) DEFAULT 'gallery',
uploaded_at TIMESTAMP DEFAULT NOW()
);
`);
// Add columns if they don't exist (for existing databases)
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS medium TEXT`);
await db.query(`ALTER TABLE images ADD COLUMN IF NOT EXISTS thumbnail TEXT`);
await db.query(`
CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
date DATE NOT NULL,
time TIME NOT NULL,
guests INTEGER NOT NULL,
table_name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS coupons (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
code VARCHAR(50) NOT NULL,
discount VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS offers (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS benefits (
id SERIAL PRIMARY KEY,
text VARCHAR(500) NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS config (
key VARCHAR(100) PRIMARY KEY,
value TEXT NOT NULL
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS rooms (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS menu_items (
id SERIAL PRIMARY KEY,
category VARCHAR(100) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
price NUMERIC(10,2) NOT NULL,
is_daily_special BOOLEAN DEFAULT FALSE,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS places (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
distance_km NUMERIC(8,2),
visit_time VARCHAR(100),
suggestion TEXT,
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS reviews (
id SERIAL PRIMARY KEY,
author_name VARCHAR(255) NOT NULL,
rating INTEGER NOT NULL CHECK (rating BETWEEN 1 AND 5),
text TEXT NOT NULL,
approved BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS slides (
id SERIAL PRIMARY KEY,
title VARCHAR(255),
subtitle TEXT,
image_id INTEGER REFERENCES images(id) ON DELETE SET NULL,
image_url VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_comments (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
photo_url VARCHAR(500),
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
first_name VARCHAR(100) NOT NULL,
last_initial VARCHAR(10),
text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
deleted_by_admin BOOLEAN DEFAULT FALSE
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS social_events (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
price VARCHAR(50),
date DATE,
photos TEXT[] DEFAULT '{}',
featured_photo VARCHAR(500),
active BOOLEAN DEFAULT TRUE,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS social_event_reservations (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
social_event_id INTEGER REFERENCES social_events(id) ON DELETE CASCADE,
guests INTEGER NOT NULL,
notes TEXT,
status VARCHAR(20) DEFAULT 'pending' CHECK (status IN ('pending', 'confirmed', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, social_event_id)
);
`);
await db.query(`CREATE TABLE IF NOT EXISTS calendar_events (
id SERIAL PRIMARY KEY,
date DATE NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
type VARCHAR(100) NOT NULL,
description TEXT,
color VARCHAR(20) NOT NULL,
active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);`);
await db.query(`
CREATE TABLE IF NOT EXISTS user_sessions (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS comments_disabled BOOLEAN DEFAULT FALSE;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS first_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_name VARCHAR(100);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS preferred_language VARCHAR(10) DEFAULT 'es';
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS terms_accepted_at TIMESTAMP;
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS email VARCHAR(255);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
`);
await db.query(`
ALTER TABLE users ADD COLUMN IF NOT EXISTS country VARCHAR(100);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS terms_content (
id SERIAL PRIMARY KEY,
language VARCHAR(10) NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version VARCHAR(20) NOT NULL DEFAULT '1.0',
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(language, version)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS availability (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available' CHECK (status IN ('available', 'booked', 'maintenance')),
price NUMERIC(10,2),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, date)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS trips (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
room_id INTEGER REFERENCES rooms(id) ON DELETE SET NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
notes TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'confirmed' CHECK (status IN ('pending', 'confirmed', 'checked_in', 'checked_out', 'cancelled')),
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
sender_role VARCHAR(20) NOT NULL CHECK (sender_role IN ('user', 'admin')),
message TEXT NOT NULL,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_amenities (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
icon_svg TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_amenity_links (
room_id INTEGER REFERENCES rooms(id) ON DELETE CASCADE,
amenity_id INTEGER REFERENCES room_amenities(id) ON DELETE CASCADE,
PRIMARY KEY (room_id, amenity_id)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS reservations (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
user_id INTEGER NOT NULL REFERENCES users(id),
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_price DECIMAL(10,2),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS room_availability (
id SERIAL PRIMARY KEY,
room_id INTEGER NOT NULL REFERENCES rooms(id),
date DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'available',
reason TEXT,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE(room_id, date)
);
`);
await db.query(`
CREATE TABLE IF NOT EXISTS messages (
id SERIAL PRIMARY KEY,
room_id INTEGER REFERENCES rooms(id),
user_id INTEGER NOT NULL REFERENCES users(id),
message TEXT NOT NULL,
read_by_admins BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
`);
await db.query(`ALTER TABLE room_comments ADD COLUMN IF NOT EXISTS status VARCHAR(20) DEFAULT 'approved'`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_reservations_room_dates ON reservations(room_id, check_in, check_out)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_room_availability_room_date ON room_availability(room_id, date)`);
await db.query(`CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at DESC)`);
}
export async function migrateFromLegacyPlaces() {
await db.query(`
UPDATE places SET photos = ARRAY[photo] WHERE photo IS NOT NULL AND (photos IS NULL OR photos = '{}');
`);
await db.query(`
ALTER TABLE places DROP COLUMN IF EXISTS photo;
`);
await db.query(`
ALTER TABLE places ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
await db.query(`
ALTER TABLE rooms ADD COLUMN IF NOT EXISTS featured_photo VARCHAR(500);
`);
}
export async function seed() {
if (process.env.NODE_ENV === 'production') {
throw new Error('Seeding is disabled in production');
}
const adminPassword = process.env.BOOTSTRAP_ADMIN_PASSWORD;
const userPassword = process.env.BOOTSTRAP_USER_PASSWORD;
const wifiPassword = process.env.BOOTSTRAP_WIFI_PASSWORD;
const tagline = process.env.BOOTSTRAP_TAGLINE || 'Hotel · Restaurant · Andean Highlands';
const { rows: adminRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
if (adminRows.length === 0 && adminPassword) {
const adminHash = bcrypt.hashSync(adminPassword, 10);
const seedRows: Array<[string, string, string]> = [['admin', adminHash, 'admin']];
if (userPassword) {
seedRows.push(['user', bcrypt.hashSync(userPassword, 10), 'user']);
}
await db.query(
`INSERT INTO users (username, password_hash, role) VALUES ${seedRows
.map((_, index) => `($${index * 3 + 1}, $${index * 3 + 2}, $${index * 3 + 3})`)
.join(', ')}`,
seedRows.flat()
);
}
const { rows: offerRows } = await db.query(`SELECT id FROM offers`);
if (offerRows.length === 0) {
await db.query(
`INSERT INTO offers (title, description) VALUES ($1, $2), ($3, $4)`,
[
'Summer Wine Weekend',
'Complimentary wine tasting with every two-night stay.',
'Sunset Dinner',
'Three-course menu on the terrace every Friday.',
]
);
}
const { rows: benefitRows } = await db.query(`SELECT id FROM benefits`);
if (benefitRows.length === 0) {
await db.query(
`INSERT INTO benefits (text) VALUES ($1), ($2), ($3), ($4)`,
[
'Priority booking for peak weekends',
'Late checkout on availability',
'Free welcome cocktail',
'Member-only events and tastings',
]
);
}
const { rows: configWifiRows } = await db.query(`SELECT key FROM config WHERE key = 'wifi_password'`);
if (configWifiRows.length === 0 && wifiPassword) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['wifi_password', wifiPassword]);
}
const { rows: configTaglineRows } = await db.query(`SELECT key FROM config WHERE key = 'tagline'`);
if (configTaglineRows.length === 0) {
await db.query(`INSERT INTO config (key, value) VALUES ($1, $2)`, ['tagline', tagline]);
}
const { rows: calendarRows } = await db.query(`SELECT id FROM calendar_events`);
if (calendarRows.length === 0) {
await db.query(
`INSERT INTO calendar_events (date, title, type, description, color, active) VALUES ($1, $2, $3, $4, $5, $6)`,
['2026-06-30', 'Welcome Dinner', 'event', 'Andean tasting menu', '#b45309', true]
);
}
const { rows: socialRows } = await db.query(`SELECT id FROM social_events`);
if (socialRows.length === 0) {
await db.query(
`INSERT INTO social_events (name, description, price, date, photos, featured_photo, active, sort_order) VALUES ($1, $2, $3, $4, '{}', null, $5, $6)`,
['Sunset Tasting', 'Local wines on the terrace.', 'USD 35', '2026-07-04', true, 1]
);
}
const { rows: socialReservationRows } = await db.query(`SELECT id FROM social_event_reservations`);
if (socialReservationRows.length === 0) {
const { rows: adminIdRows } = await db.query(`SELECT id FROM users WHERE username = 'admin'`);
if (adminIdRows.length > 0) {
await db.query(
`INSERT INTO social_event_reservations (user_id, social_event_id, guests, notes, status) SELECT $1, id, $2, $3, $4 FROM social_events LIMIT 1`,
[adminIdRows[0].id, 2, 'Anniversary', 'confirmed']
);
}
}
const { rows: amenityRows } = await db.query(`SELECT id FROM room_amenities`);
if (amenityRows.length === 0) {
const defaultAmenities = [
['Wi-Fi', '', 1],
['TV', '', 2],
['Shower', '', 3],
['Bathtub', '', 4],
['Fireplace', '', 5],
['Minibar', '', 6],
['Coffee Maker', '', 7],
['Safe', '', 8],
['Heating', '', 9],
['Balcony', '', 10],
['King Bed', '', 11],
['Queen Bed', '', 13],
['2 Queen Beds', '', 14],
['Mountain View', '', 12],
];
for (const [name, icon_svg, sort_order] of defaultAmenities) {
await db.query(
'INSERT INTO room_amenities (name, icon_svg, sort_order) VALUES ($1, $2, $3)',
[name, icon_svg, sort_order]
);
}
}
}
export async function resetDatabase() {
await db.query('DROP TABLE IF EXISTS room_comments, reviews, places, menu_items, rooms, config, benefits, offers, coupons, reservations, images, users, slides, calendar_events, social_events, social_event_reservations CASCADE');
await initSchema();
await migrateFromLegacyPlaces();
await seed();
}