rates/backend/schema.sql
jtricerolph e481ad5258 Add Power BI reporting endpoints — flat tabular data via API key auth
Three read-only endpoints under /reporting/:
- /hotels       hotel dimension table (tier, stars, review score)
- /rates        full rates fact — all sources, room types, scrape history in one flat table
- /occupancy    Newbook occupancy per date × room category

Rates UNION covers Booking.com scrapes, direct competitor engines (with
configured room/rate labels), and own hotel Newbook headline rates.
Authenticated via X-API-Key header; key stored in system_config.reporting_api_key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 17:27:50 +00:00

326 lines
13 KiB
SQL

-- Rate Monitor — Database Schema
-- All CREATE TABLE/INDEX are idempotent (IF NOT EXISTS)
-- ============================================
-- SYSTEM CONFIG
-- ============================================
CREATE TABLE IF NOT EXISTS system_config (
config_key VARCHAR(100) PRIMARY KEY,
config_value TEXT,
description TEXT,
updated_at TIMESTAMPTZ DEFAULT NOW(),
updated_by VARCHAR(255)
);
INSERT INTO system_config (config_key, config_value, description) VALUES
('booking_scraper_enabled', 'false', 'Enable automatic booking.com rate scraping (true/false)'),
('booking_scraper_paused', 'false', 'Scraper temporarily paused due to blocking (true/false)'),
('booking_scraper_pause_until', NULL, 'ISO datetime when pause expires'),
('booking_scraper_backend', 'playwright_local', 'Scraper backend: playwright_local | playwright_hotel_page | apify'),
('booking_scraper_daily_time', '05:30', 'Daily scrape time (HH:MM)'),
('booking_scraper_proxy_url', NULL, 'Proxy URL for playwright_proxy backend'),
('booking_scraper_proxy_username', NULL, 'Proxy username for playwright_proxy backend'),
('booking_scraper_proxy_password', NULL, 'Proxy password for playwright_proxy backend'),
('sync_newbook_current_rates_enabled', 'false', 'Enable automatic Newbook current rates sync (true/false)'),
('sync_newbook_current_rates_time', '05:20', 'Newbook current rates sync time (HH:MM)'),
('reporting_api_key', NULL, 'Static API key for Power BI reporting endpoints (X-API-Key header)')
ON CONFLICT (config_key) DO NOTHING;
-- ============================================
-- BOOKING.COM SCRAPE LOG (batch tracking)
-- ============================================
CREATE TABLE IF NOT EXISTS booking_scrape_log (
batch_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
scrape_type VARCHAR(50) NOT NULL,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
status VARCHAR(20) NOT NULL DEFAULT 'running',
hotels_found INTEGER DEFAULT 0,
rates_scraped INTEGER DEFAULT 0,
dates_queued INTEGER DEFAULT 0,
dates_completed INTEGER DEFAULT 0,
dates_failed INTEGER DEFAULT 0,
error_message TEXT,
blocked_at TIMESTAMPTZ,
resume_after TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_booking_scrape_log_started ON booking_scrape_log(started_at DESC);
-- ============================================
-- BOOKING.COM SCRAPE CONFIG
-- ============================================
CREATE TABLE IF NOT EXISTS booking_scrape_config (
id SERIAL PRIMARY KEY,
location_name VARCHAR(255) NOT NULL,
location_search_url TEXT,
pages_to_scrape INTEGER DEFAULT 2,
adults INTEGER DEFAULT 2,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- BOOKING.COM SCRAPE QUEUE
-- ============================================
CREATE TABLE IF NOT EXISTS booking_scrape_queue (
id SERIAL PRIMARY KEY,
rate_date DATE NOT NULL,
status VARCHAR(20) DEFAULT 'pending',
priority INTEGER DEFAULT 0,
attempts INTEGER DEFAULT 0,
max_attempts INTEGER DEFAULT 3,
last_attempt_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
error_message TEXT,
UNIQUE (rate_date, status)
);
CREATE INDEX IF NOT EXISTS idx_booking_scrape_queue_pending
ON booking_scrape_queue(status, priority DESC, rate_date ASC)
WHERE status = 'pending';
-- ============================================
-- BOOKING.COM HOTELS
-- ============================================
CREATE TABLE IF NOT EXISTS booking_com_hotels (
id SERIAL PRIMARY KEY,
booking_com_id VARCHAR(255) UNIQUE,
name VARCHAR(255) NOT NULL,
booking_com_url TEXT,
star_rating DECIMAL(2,1),
review_score DECIMAL(3,1),
review_count INTEGER DEFAULT 0,
tier VARCHAR(20) DEFAULT 'market', -- own | competitor | market
display_order INTEGER DEFAULT 999,
notes TEXT,
is_active BOOLEAN DEFAULT TRUE,
first_seen_at TIMESTAMPTZ DEFAULT NOW(),
last_seen_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- BOOKING.COM RATES
-- ============================================
CREATE TABLE IF NOT EXISTS booking_com_rates (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES booking_com_hotels(id) ON DELETE CASCADE,
rate_date DATE NOT NULL,
availability_status VARCHAR(30), -- available | sold_out | no_data
rate_gross DECIMAL(10,2),
currency VARCHAR(10) DEFAULT 'GBP',
room_type TEXT,
breakfast_included BOOLEAN DEFAULT FALSE,
free_cancellation BOOLEAN DEFAULT FALSE,
no_prepayment BOOLEAN DEFAULT FALSE,
rooms_left INTEGER,
scrape_batch_id UUID REFERENCES booking_scrape_log(batch_id),
scraped_at TIMESTAMPTZ DEFAULT NOW()
);
-- Widen columns on tables created before the type changes
ALTER TABLE booking_com_rates ALTER COLUMN room_type TYPE TEXT;
ALTER TABLE booking_com_hotels ALTER COLUMN booking_com_id TYPE VARCHAR(255);
-- Hotel page scraper: rate plan identifier and occupancy per row
ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS rate_plan_id TEXT;
ALTER TABLE booking_com_rates ADD COLUMN IF NOT EXISTS max_persons INTEGER;
CREATE INDEX IF NOT EXISTS idx_booking_com_rates_hotel_date ON booking_com_rates(hotel_id, rate_date);
CREATE INDEX IF NOT EXISTS idx_booking_com_rates_date ON booking_com_rates(rate_date);
CREATE INDEX IF NOT EXISTS idx_booking_com_rates_scraped ON booking_com_rates(scraped_at DESC);
-- ============================================
-- RATE PARITY ALERTS
-- ============================================
CREATE TABLE IF NOT EXISTS rate_parity_alerts (
id SERIAL PRIMARY KEY,
rate_date DATE NOT NULL,
room_category VARCHAR(255),
newbook_rate DECIMAL(12,2),
booking_com_rate DECIMAL(12,2),
difference_pct DECIMAL(8,2),
alert_type VARCHAR(20), -- higher | lower
alert_status VARCHAR(20) DEFAULT 'active', -- active | acknowledged
created_at TIMESTAMPTZ DEFAULT NOW(),
acknowledged_at TIMESTAMPTZ,
acknowledged_by VARCHAR(255),
notes TEXT
);
CREATE INDEX IF NOT EXISTS idx_rate_parity_alerts_date ON rate_parity_alerts(rate_date DESC);
CREATE INDEX IF NOT EXISTS idx_rate_parity_alerts_status ON rate_parity_alerts(alert_status);
-- ============================================
-- NEWBOOK CURRENT RATES (own hotel, for bookability display)
-- ============================================
CREATE TABLE IF NOT EXISTS newbook_current_rates (
id SERIAL PRIMARY KEY,
category_id VARCHAR(50) NOT NULL,
rate_date DATE NOT NULL,
rate_gross DECIMAL(12,2),
rate_net DECIMAL(12,2),
tariffs_data JSONB DEFAULT '{}',
valid_from TIMESTAMP DEFAULT NOW(),
last_verified_at TIMESTAMP DEFAULT NOW()
);
-- Migrate tables created before the gross_rate/net_rate → rate_gross/rate_net rename
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'newbook_current_rates' AND column_name = 'gross_rate') THEN
ALTER TABLE newbook_current_rates RENAME COLUMN gross_rate TO rate_gross;
ALTER TABLE newbook_current_rates RENAME COLUMN net_rate TO rate_net;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_current_rates_date ON newbook_current_rates(rate_date);
CREATE INDEX IF NOT EXISTS idx_current_rates_category ON newbook_current_rates(category_id);
CREATE INDEX IF NOT EXISTS idx_current_rates_tariffs ON newbook_current_rates USING gin(tariffs_data);
CREATE INDEX IF NOT EXISTS idx_current_rates_latest ON newbook_current_rates(category_id, rate_date, valid_from DESC);
-- ============================================
-- NEWBOOK ROOM CATEGORIES (for bookability display)
-- ============================================
CREATE TABLE IF NOT EXISTS newbook_room_categories (
id SERIAL PRIMARY KEY,
site_id VARCHAR(50) NOT NULL UNIQUE,
site_name VARCHAR(255) NOT NULL,
site_type VARCHAR(100),
room_count INTEGER DEFAULT 0,
is_included BOOLEAN DEFAULT TRUE,
display_order INTEGER DEFAULT 0,
fetched_at TIMESTAMP DEFAULT NOW()
);
-- ============================================
-- NEWBOOK OCCUPANCY REPORT DATA
-- ============================================
CREATE TABLE IF NOT EXISTS newbook_occupancy_report_data (
id SERIAL PRIMARY KEY,
date DATE NOT NULL,
category_id VARCHAR(50) NOT NULL,
category_name VARCHAR(255),
available INTEGER DEFAULT 0,
occupied INTEGER DEFAULT 0,
maintenance INTEGER DEFAULT 0,
allotted INTEGER DEFAULT 0,
revenue_gross DECIMAL(12,2) DEFAULT 0,
revenue_net DECIMAL(12,2) DEFAULT 0,
occupancy_pct DECIMAL(5,2),
fetched_at TIMESTAMP DEFAULT NOW(),
UNIQUE(date, category_id)
);
CREATE INDEX IF NOT EXISTS idx_occupancy_report_date ON newbook_occupancy_report_data(date);
-- Migrate occupancy table to snapshot model (drop unique constraint, add valid_from / last_verified_at)
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
WHERE t.relname = 'newbook_occupancy_report_data'
AND c.contype = 'u'
AND c.conname = 'newbook_occupancy_report_data_date_category_id_key'
) THEN
ALTER TABLE newbook_occupancy_report_data
DROP CONSTRAINT newbook_occupancy_report_data_date_category_id_key;
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'valid_from'
) THEN
ALTER TABLE newbook_occupancy_report_data ADD COLUMN valid_from TIMESTAMPTZ;
UPDATE newbook_occupancy_report_data SET valid_from = fetched_at WHERE valid_from IS NULL;
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN valid_from SET DEFAULT NOW();
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'newbook_occupancy_report_data' AND column_name = 'last_verified_at'
) THEN
ALTER TABLE newbook_occupancy_report_data ADD COLUMN last_verified_at TIMESTAMPTZ;
UPDATE newbook_occupancy_report_data SET last_verified_at = fetched_at WHERE last_verified_at IS NULL;
ALTER TABLE newbook_occupancy_report_data ALTER COLUMN last_verified_at SET DEFAULT NOW();
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_occupancy_report_latest
ON newbook_occupancy_report_data(date, category_id, valid_from DESC);
-- ============================================
-- DIRECT COMPETITOR HOTEL CONFIGS
-- ============================================
CREATE TABLE IF NOT EXISTS direct_competitor_hotels (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
profile_name VARCHAR(50) NOT NULL,
params JSONB NOT NULL DEFAULT '{}',
room_labels JSONB DEFAULT '{}',
rate_labels JSONB DEFAULT '{}',
room_order JSONB DEFAULT '[]',
benchmark_room VARCHAR(100),
benchmark_rate VARCHAR(100),
tier_base_room VARCHAR(100),
tier_offsets JSONB DEFAULT '{}',
scrape_enabled BOOLEAN DEFAULT TRUE,
last_scraped_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ============================================
-- DIRECT SCRAPE RUNS
-- ============================================
CREATE TABLE IF NOT EXISTS direct_scrape_runs (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES direct_competitor_hotels(id) ON DELETE CASCADE,
scraped_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
dates_found INTEGER DEFAULT 0,
rows_saved INTEGER DEFAULT 0
);
-- ============================================
-- DIRECT RATES (per-room snapshots from booking engines)
-- ============================================
CREATE TABLE IF NOT EXISTS direct_rates (
id SERIAL PRIMARY KEY,
hotel_id INTEGER NOT NULL REFERENCES direct_competitor_hotels(id) ON DELETE CASCADE,
scrape_run_id INTEGER REFERENCES direct_scrape_runs(id),
scraped_at TIMESTAMPTZ NOT NULL,
stay_date DATE NOT NULL,
room_id VARCHAR(100) NOT NULL,
rate_id VARCHAR(100) NOT NULL,
availability INTEGER DEFAULT 0,
price_excl DECIMAL(10,2),
price_incl DECIMAL(10,2),
currency VARCHAR(10) DEFAULT 'GBP',
min_stay_nights INTEGER
);
CREATE INDEX IF NOT EXISTS idx_direct_rates_hotel_date ON direct_rates(hotel_id, stay_date);
CREATE INDEX IF NOT EXISTS idx_direct_rates_scraped ON direct_rates(scraped_at DESC);
-- Link booking_com_hotels to direct_competitor_hotels (optional, for Market View direct column)
ALTER TABLE booking_com_hotels ADD COLUMN IF NOT EXISTS direct_hotel_id INTEGER
REFERENCES direct_competitor_hotels(id) ON DELETE SET NULL;
-- Pin the Booking.com destination: free-text ss= searches non-deterministically
-- resolve to the wrong place (Stow on the Wold once matched St. Wolfgang, AT).
ALTER TABLE booking_scrape_config ADD COLUMN IF NOT EXISTS dest_id VARCHAR(32);