feat: implement reverse scan (country → specific airports)
All checks were successful
Deploy / deploy (push) Successful in 30s

- 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>
This commit is contained in:
2026-03-01 17:58:55 +01:00
parent 7ece1f9b45
commit 77d2a46264
9 changed files with 1070 additions and 279 deletions

View File

@@ -20,8 +20,11 @@ CREATE TABLE IF NOT EXISTS scans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
-- Search parameters (validated by CHECK constraints)
origin TEXT NOT NULL CHECK(length(origin) = 3),
-- 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,
@@ -81,7 +84,10 @@ CREATE TABLE IF NOT EXISTS routes (
-- Foreign key to scans (cascade delete)
scan_id INTEGER NOT NULL,
-- Destination airport
-- 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,
@@ -120,9 +126,9 @@ 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, destination) — enables incremental upsert writes
CREATE UNIQUE INDEX IF NOT EXISTS uq_routes_scan_dest
ON routes(scan_id, destination);
-- 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
@@ -191,6 +197,8 @@ CREATE TABLE IF NOT EXISTS flights (
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
@@ -273,8 +281,10 @@ 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) = 3),
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',