Files
domverse 77d2a46264
All checks were successful
Deploy / deploy (push) Successful in 30s
feat: implement reverse scan (country → specific airports)
- DB schema: relaxed origin CHECK to >=2 chars, added scan_mode column to
  scans and scheduled_scans, added origin_airport to routes and flights,
  updated unique index to (scan_id, COALESCE(origin_airport,''), destination)
- Migrations: init_db.py recreates tables and adds columns via guarded ALTERs
- API: scan_mode field on ScanRequest/Scan; Route/Flight expose origin_airport;
  GET /scans/{id}/flights accepts origin_airport filter; CreateScheduleRequest
  and Schedule carry scan_mode; scheduler and run-now pass scan_mode through
- scan_processor: _write_route_incremental accepts origin_airport; process_scan
  branches on scan_mode=reverse (country → airports × destinations × dates)
- Frontend: new CountrySelect component (populated from GET /api/v1/countries);
  Scans page adds Direction toggle + CountrySelect for both modes; ScanDetails
  shows Origin column for reverse scans and uses composite route keys; Re-run
  preserves scan_mode

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 17:58:55 +01:00

345 lines
12 KiB
SQL

-- Flight Radar Web App - Database Schema
-- Version: 2.0
-- Date: 2026-02-23
-- Database: SQLite 3
--
-- This schema extends the existing cache.db with new tables for the web app.
-- Existing tables (flight_searches, flight_results) are preserved.
-- ============================================================================
-- CRITICAL: Enable Foreign Keys (SQLite default is OFF!)
-- ============================================================================
PRAGMA foreign_keys = ON;
-- ============================================================================
-- Table: scans
-- Purpose: Track flight scan requests and their status
-- ============================================================================
CREATE TABLE IF NOT EXISTS scans (
-- Primary key with auto-increment
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Search parameters (validated by CHECK constraints)
-- origin stores IATA code (forward scans) or ISO country code (reverse scans)
origin TEXT NOT NULL CHECK(length(origin) >= 2),
country TEXT NOT NULL CHECK(length(country) >= 2),
scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK(scan_mode IN ('forward', 'reverse')),
start_date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD
end_date TEXT NOT NULL,
-- Timestamps (auto-managed)
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMP, -- Set when status transitions to 'running'
completed_at TIMESTAMP, -- Set when status transitions to 'completed' or 'failed'
-- Scan status (enforced enum via CHECK)
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending', 'running', 'completed', 'failed', 'cancelled', 'paused')),
-- Progress tracking
total_routes INTEGER NOT NULL DEFAULT 0 CHECK(total_routes >= 0),
routes_scanned INTEGER NOT NULL DEFAULT 0 CHECK(routes_scanned >= 0),
total_flights INTEGER NOT NULL DEFAULT 0 CHECK(total_flights >= 0),
-- Error information (NULL if no error)
error_message TEXT,
-- Additional search parameters
seat_class TEXT DEFAULT 'economy',
adults INTEGER DEFAULT 1 CHECK(adults > 0 AND adults <= 9),
-- FK to scheduled_scans (NULL for manual scans)
scheduled_scan_id INTEGER,
-- Constraints across columns
CHECK(end_date >= start_date),
CHECK(routes_scanned <= total_routes OR total_routes = 0)
);
-- Performance indexes for scans table
CREATE INDEX IF NOT EXISTS idx_scans_origin_country
ON scans(origin, country);
CREATE INDEX IF NOT EXISTS idx_scans_status
ON scans(status)
WHERE status IN ('pending', 'running'); -- Partial index for active scans
CREATE INDEX IF NOT EXISTS idx_scans_created_at
ON scans(created_at DESC); -- For recent scans query
CREATE INDEX IF NOT EXISTS idx_scans_scheduled_scan_id
ON scans(scheduled_scan_id)
WHERE scheduled_scan_id IS NOT NULL;
-- ============================================================================
-- Table: routes
-- Purpose: Store discovered routes with flight statistics
-- ============================================================================
CREATE TABLE IF NOT EXISTS routes (
-- Primary key
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Foreign key to scans (cascade delete)
scan_id INTEGER NOT NULL,
-- Route airports
-- For forward scans: origin_airport is NULL (implicit from scan.origin)
-- For reverse scans: origin_airport is the variable origin IATA
origin_airport TEXT,
destination TEXT NOT NULL CHECK(length(destination) = 3),
destination_name TEXT NOT NULL,
destination_city TEXT,
-- Flight statistics
flight_count INTEGER NOT NULL DEFAULT 0 CHECK(flight_count >= 0),
airlines TEXT NOT NULL, -- JSON array: ["Ryanair", "Lufthansa"]
-- Price statistics (NULL if no flights)
min_price REAL CHECK(min_price >= 0),
max_price REAL CHECK(max_price >= 0),
avg_price REAL CHECK(avg_price >= 0),
-- Timestamp
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Foreign key constraint with cascade delete
FOREIGN KEY (scan_id)
REFERENCES scans(id)
ON DELETE CASCADE,
-- Price consistency constraints
CHECK(max_price >= min_price OR max_price IS NULL),
CHECK(avg_price >= min_price OR avg_price IS NULL),
CHECK(avg_price <= max_price OR avg_price IS NULL)
);
-- Performance indexes for routes table
CREATE INDEX IF NOT EXISTS idx_routes_scan_id
ON routes(scan_id);
CREATE INDEX IF NOT EXISTS idx_routes_destination
ON routes(destination);
CREATE INDEX IF NOT EXISTS idx_routes_min_price
ON routes(min_price)
WHERE min_price IS NOT NULL; -- Partial index for routes with prices
-- One route row per (scan, origin_airport, destination) — supports both forward and reverse scans
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_origin_dest
ON routes(scan_id, COALESCE(origin_airport, ''), destination);
-- ============================================================================
-- Triggers: Auto-update timestamps and aggregates
-- ============================================================================
-- Trigger: Update scans.updated_at on any update
CREATE TRIGGER IF NOT EXISTS update_scans_timestamp
AFTER UPDATE ON scans
FOR EACH ROW
BEGIN
UPDATE scans
SET updated_at = CURRENT_TIMESTAMP
WHERE id = NEW.id;
END;
-- Trigger: Update total_flights count when routes are inserted
CREATE TRIGGER IF NOT EXISTS update_scan_flight_count_insert
AFTER INSERT ON routes
FOR EACH ROW
BEGIN
UPDATE scans
SET total_flights = (
SELECT COALESCE(SUM(flight_count), 0)
FROM routes
WHERE scan_id = NEW.scan_id
)
WHERE id = NEW.scan_id;
END;
-- Trigger: Update total_flights count when routes are updated
CREATE TRIGGER IF NOT EXISTS update_scan_flight_count_update
AFTER UPDATE OF flight_count ON routes
FOR EACH ROW
BEGIN
UPDATE scans
SET total_flights = (
SELECT COALESCE(SUM(flight_count), 0)
FROM routes
WHERE scan_id = NEW.scan_id
)
WHERE id = NEW.scan_id;
END;
-- Trigger: Update total_flights count when routes are deleted
CREATE TRIGGER IF NOT EXISTS update_scan_flight_count_delete
AFTER DELETE ON routes
FOR EACH ROW
BEGIN
UPDATE scans
SET total_flights = (
SELECT COALESCE(SUM(flight_count), 0)
FROM routes
WHERE scan_id = OLD.scan_id
)
WHERE id = OLD.scan_id;
END;
-- ============================================================================
-- Table: flights
-- Purpose: Store individual flights discovered per scan
-- ============================================================================
CREATE TABLE IF NOT EXISTS flights (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Foreign key to scans (cascade delete)
scan_id INTEGER NOT NULL,
-- Route
-- origin_airport: NULL for forward scans, specific IATA for reverse scans
origin_airport TEXT,
destination TEXT NOT NULL CHECK(length(destination) = 3),
date TEXT NOT NULL, -- ISO 8601: YYYY-MM-DD
-- Flight details
airline TEXT,
departure_time TEXT, -- HH:MM
arrival_time TEXT, -- HH:MM
price REAL CHECK(price >= 0),
stops INTEGER NOT NULL DEFAULT 0,
-- Timestamp
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (scan_id)
REFERENCES scans(id)
ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_flights_scan_id
ON flights(scan_id);
CREATE INDEX IF NOT EXISTS idx_flights_scan_dest
ON flights(scan_id, destination);
CREATE INDEX IF NOT EXISTS idx_flights_price
ON flights(scan_id, price ASC)
WHERE price IS NOT NULL;
-- ============================================================================
-- Views: Useful queries
-- ============================================================================
-- View: Recent scans with route counts
CREATE VIEW IF NOT EXISTS recent_scans AS
SELECT
s.id,
s.origin,
s.country,
s.status,
s.created_at,
s.total_routes,
s.total_flights,
COUNT(r.id) as routes_found,
MIN(r.min_price) as cheapest_flight,
s.error_message
FROM scans s
LEFT JOIN routes r ON r.scan_id = s.id
GROUP BY s.id
ORDER BY s.created_at DESC
LIMIT 10;
-- View: Active scans (pending or running)
CREATE VIEW IF NOT EXISTS active_scans AS
SELECT *
FROM scans
WHERE status IN ('pending', 'running')
ORDER BY created_at ASC;
-- ============================================================================
-- Initial Data: None (tables start empty)
-- ============================================================================
-- ============================================================================
-- Schema version tracking (for future migrations)
-- ============================================================================
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
description TEXT
);
INSERT OR IGNORE INTO schema_version (version, description)
VALUES (1, 'Initial web app schema with scans and routes tables');
-- ============================================================================
-- Table: scheduled_scans
-- Purpose: Define recurring scan schedules (daily / weekly / monthly)
-- ============================================================================
CREATE TABLE IF NOT EXISTS scheduled_scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Scan parameters (same as scans table)
origin TEXT NOT NULL CHECK(length(origin) >= 2),
country TEXT NOT NULL CHECK(length(country) >= 2),
scan_mode TEXT NOT NULL DEFAULT 'forward'
CHECK(scan_mode IN ('forward', 'reverse')),
window_months INTEGER NOT NULL DEFAULT 1
CHECK(window_months >= 1 AND window_months <= 12),
seat_class TEXT NOT NULL DEFAULT 'economy',
adults INTEGER NOT NULL DEFAULT 1
CHECK(adults > 0 AND adults <= 9),
-- Schedule definition
frequency TEXT NOT NULL
CHECK(frequency IN ('daily', 'weekly', 'monthly')),
hour INTEGER NOT NULL DEFAULT 6
CHECK(hour >= 0 AND hour <= 23),
minute INTEGER NOT NULL DEFAULT 0
CHECK(minute >= 0 AND minute <= 59),
day_of_week INTEGER CHECK(day_of_week >= 0 AND day_of_week <= 6),
day_of_month INTEGER CHECK(day_of_month >= 1 AND day_of_month <= 28),
-- State
enabled INTEGER NOT NULL DEFAULT 1,
label TEXT,
last_run_at TIMESTAMP,
next_run_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Frequency-specific field requirements
CHECK(
(frequency = 'weekly' AND day_of_week IS NOT NULL) OR
(frequency = 'monthly' AND day_of_month IS NOT NULL) OR
(frequency = 'daily')
)
);
-- Fast lookup of due schedules (partial index on enabled rows only)
CREATE INDEX IF NOT EXISTS idx_scheduled_scans_next_run
ON scheduled_scans(next_run_at)
WHERE enabled = 1;
-- Auto-update updated_at on every PATCH
CREATE TRIGGER IF NOT EXISTS update_scheduled_scans_timestamp
AFTER UPDATE ON scheduled_scans
FOR EACH ROW BEGIN
UPDATE scheduled_scans SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
INSERT OR IGNORE INTO schema_version (version, description)
VALUES (2, 'Add scheduled_scans table');
-- ============================================================================
-- Verification Queries (for testing)
-- ============================================================================
-- Uncomment to verify schema creation:
-- SELECT name, type FROM sqlite_master WHERE type IN ('table', 'index', 'trigger', 'view') ORDER BY type, name;
-- PRAGMA foreign_keys;
-- PRAGMA table_info(scans);
-- PRAGMA table_info(routes);