diff --git a/PRD-reverse-scan.md b/PRD-reverse-scan.md new file mode 100644 index 0000000..9252398 --- /dev/null +++ b/PRD-reverse-scan.md @@ -0,0 +1,321 @@ +# PRD: Reverse Scan — Country → Specific Airport(s) + +**Status:** Ready for implementation +**Date:** 2026-03-01 +**Target:** Web App (api_server.py + scan_processor.py + database + frontend) + +--- + +## 1. Problem + +All scans today are fixed-origin, variable-destination: +> *"From BDS — which German airports can I fly to directly?"* + +The opposite question is equally common but unsupported: +> *"I want to fly to BRI or BDS — which German airport should I depart from?"* + +This is the natural perspective of a traveller based in Germany looking for the cheapest access point to Puglia, not a traveller already in Puglia wondering where to go. + +--- + +## 2. Goal + +Let users create a scan where: +- **Origin** = all airports in a country (e.g. DE) +- **Destination** = one or more specific airports (e.g. BRI, BDS) + +The results table for a reverse scan shows one row per **origin airport × destination** pair, so the user can directly compare which German airport is cheapest for each destination. + +--- + +## 3. User Stories + +- **As a user**, I want to search DE → BRI,BDS so I can see which German airport has the cheapest direct flight to Puglia. +- **As a user**, I want to schedule a reverse scan to run weekly. +- **As a user**, I want to mix both directions in my scan history. +- **As a user**, I want the destination country in forward scans to be a dropdown, not free text. + +--- + +## 4. Scope + +### In scope +- `scan_mode` field on scans and scheduled scans: `"forward"` (default) or `"reverse"` +- Reverse scan: origin = ISO country (dropdown), destinations = specific airport(s) +- Forward scan: destination country changed from text input to dropdown +- New `GET /api/v1/countries` endpoint (powers both dropdowns) +- `routes` table: add `origin_airport` column (nullable; populated for reverse scans) +- `flights` table: add `origin_airport` column (nullable; populated for reverse scans) +- ScanDetails routes table: show Origin column for reverse scans +- Scheduled scans: support `scan_mode` + +### Out of scope +- Multi-country origin (e.g. DE + IT → BRI) +- Round-trip reverse scans + +--- + +## 5. Data Model Changes + +### 5.1 `scans` table + +```sql +ALTER TABLE scans ADD COLUMN scan_mode TEXT NOT NULL DEFAULT 'forward' + CHECK (scan_mode IN ('forward', 'reverse')); +``` + +Column semantics by mode: + +| Column | Forward (existing) | Reverse (new) | +|--------|--------------------|---------------| +| `origin` | Single IATA (e.g. `BDS`) | ISO country code (e.g. `DE`) | +| `country` | ISO destination country OR comma-separated destination IATAs | Comma-separated destination IATAs (e.g. `BRI,BDS`) | +| `scan_mode` | `forward` | `reverse` | + +All existing rows default to `forward`. No data migration needed. + +### 5.2 `routes` table + +```sql +ALTER TABLE routes ADD COLUMN origin_airport TEXT; +``` + +- **Forward scans**: `origin_airport` = NULL (origin is always the scan-level `origin` IATA, already known) +- **Reverse scans**: `origin_airport` = the specific origin IATA for this route (e.g. `BER`, `FRA`) + +The unique constraint on routes changes from `(scan_id, destination)` to `(scan_id, COALESCE(origin_airport, ''), destination)`. + +**Reverse scan routes example** for `DE → BRI, BDS`: + +| origin_airport | destination | flight_count | min_price | +|----------------|-------------|--------------|-----------| +| BER | BRI | 14 | €15 | +| FMM | BRI | 8 | €17 | +| DUS | BRI | 6 | €22 | +| BER | BDS | 3 | €28 | +| MUC | BDS | 2 | €34 | + +### 5.3 `flights` table + +```sql +ALTER TABLE flights ADD COLUMN origin_airport TEXT; +``` + +- **Forward scans**: `origin_airport` = NULL (origin is the scan-level `origin` IATA) +- **Reverse scans**: `origin_airport` = the specific IATA the flight departs from + +### 5.4 `scheduled_scans` table + +```sql +ALTER TABLE scheduled_scans ADD COLUMN scan_mode TEXT NOT NULL DEFAULT 'forward' + CHECK (scan_mode IN ('forward', 'reverse')); +``` + +Same column semantics as `scans`. When the scheduler fires a reverse scheduled scan, it creates a reverse scan child. + +--- + +## 6. New API Endpoint + +### `GET /api/v1/countries` + +Returns the list of countries that have at least one airport in the database. Powers both the forward-mode destination country dropdown and the reverse-mode origin country dropdown. + +**Response:** +```json +{ + "data": [ + { "code": "DE", "name": "Germany", "airport_count": 23 }, + { "code": "IT", "name": "Italy", "airport_count": 41 }, + ... + ] +} +``` + +Sorted alphabetically by name. Derived from `data/airports_by_country.json` at startup (same source as airport resolution). Rate limit: 100/min. + +Country names are resolved from a static ISO → name mapping in `api_server.py` (same `COUNTRY_NAME_TO_ISO` dict already in `airports.py`, inverted). + +--- + +## 7. Backend Changes + +### 7.1 `ScanRequest` model + +Add `scan_mode: Literal['forward', 'reverse'] = 'forward'`. + +Root validator enforces: + +**Forward mode** (unchanged): +- `origin`: required, 3-char IATA +- `country` OR `destinations`: required + +**Reverse mode**: +- `origin`: required, 2-char ISO country code +- `destinations`: required, 1–10 IATAs +- `country` must be absent or null + +### 7.2 `scan_processor.py` — `process_scan()` + +**Forward** (unchanged): +``` +fixed origin IATA × [airports resolved from destination country / destination IATAs] +``` + +**Reverse** (new): +``` +[airports resolved from origin country] × fixed destination IATA(s) +``` + +Steps for reverse: +1. Read `scan.origin` as ISO country code → resolve airport list via `airports.py` +2. Read `scan.country` as comma-separated destination IATAs +3. Build pairs: `[(orig, dest) for orig in origin_airports for dest in destination_iatas]` +4. For each pair: call `search_multiple_routes(orig, dest)` as usual +5. Save route with `origin_airport=orig`, `destination=dest` +6. Save each flight with `origin_airport=orig` + +`total_routes` = `len(origin_airports) × len(destination_iatas)`. + +### 7.3 `GET /scans/{id}/routes` response + +Add `origin_airport: str | null` to the `Route` response model. Frontend uses this to show/hide the Origin column. + +### 7.4 `GET /scans/{id}/flights` response + +Add `origin_airport: str | null` to the `Flight` response model. + +### 7.5 Scheduled scans + +`ScheduleRequest` and `Schedule` models gain `scan_mode`. The scheduler's `_fire_due_scans()` function passes `scan_mode` when inserting child scans into the `scans` table. + +--- + +## 8. Frontend Changes + +### 8.1 New `countriesApi` client (`api.ts`) + +```typescript +export interface Country { code: string; name: string; airport_count: number; } + +export const countriesApi = { + list: () => api.get<{ data: Country[] }>('/countries'), +}; +``` + +### 8.2 New `CountrySelect` component + +A standard ` onChange(e.target.value)} + disabled={loading} + className={`${baseCls} ${className ?? ''}`} + > + + {countries.map(c => ( + + ))} + + ); +} diff --git a/flight-comparator/frontend/src/pages/ScanDetails.tsx b/flight-comparator/frontend/src/pages/ScanDetails.tsx index da030f4..4dbc47c 100644 --- a/flight-comparator/frontend/src/pages/ScanDetails.tsx +++ b/flight-comparator/frontend/src/pages/ScanDetails.tsx @@ -136,16 +136,21 @@ export default function ScanDetails() { return 0; }); - const toggleFlights = async (destination: string) => { - if (expandedRoute === destination) { setExpandedRoute(null); return; } - setExpandedRoute(destination); - if (flightsByDest[destination]) return; - setLoadingFlights(destination); + // For reverse scans, route key = "ORIG:DEST"; for forward scans = "DEST" + const routeKey = (route: Route) => + route.origin_airport ? `${route.origin_airport}:${route.destination}` : route.destination; + + const toggleFlights = async (route: Route) => { + const key = routeKey(route); + if (expandedRoute === key) { setExpandedRoute(null); return; } + setExpandedRoute(key); + if (flightsByDest[key]) return; + setLoadingFlights(key); try { - const resp = await scanApi.getFlights(Number(id), destination, 1, 200); - setFlightsByDest(prev => ({ ...prev, [destination]: resp.data.data })); + const resp = await scanApi.getFlights(Number(id), route.destination, route.origin_airport, 1, 200); + setFlightsByDest(prev => ({ ...prev, [key]: resp.data.data })); } catch { - setFlightsByDest(prev => ({ ...prev, [destination]: [] })); + setFlightsByDest(prev => ({ ...prev, [key]: [] })); } finally { setLoadingFlights(null); } @@ -155,21 +160,30 @@ export default function ScanDetails() { if (!scan) return; setRerunning(true); try { - // Compute window from stored dates so the new scan covers the same span const ms = new Date(scan.end_date).getTime() - new Date(scan.start_date).getTime(); const window_months = Math.max(1, Math.round(ms / (1000 * 60 * 60 * 24 * 30))); - // country column holds either "IT" or "BRI,BDS" - const isAirports = scan.country.includes(','); - const resp = await scanApi.create({ + const base = { + scan_mode: (scan.scan_mode ?? 'forward') as 'forward' | 'reverse', origin: scan.origin, window_months, seat_class: scan.seat_class as 'economy' | 'premium' | 'business' | 'first', adults: scan.adults, - ...(isAirports + }; + + let extra: Record; + if (scan.scan_mode === 'reverse') { + // For reverse: country column holds comma-separated dest IATAs + extra = { destinations: scan.country.split(',') }; + } else { + // For forward: country column holds ISO code or comma-separated IATAs + const isAirports = scan.country.includes(','); + extra = isAirports ? { destinations: scan.country.split(',') } - : { country: scan.country }), - }); + : { country: scan.country }; + } + + const resp = await scanApi.create({ ...base, ...extra }); navigate(`/scans/${resp.data.id}`); } catch { // silently fall through — the navigate won't happen @@ -302,7 +316,9 @@ export default function ScanDetails() {