Files
fuel-price/.claude/rules/api-data.md
Ovidiu U b4bd78ab4c
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Rename SearchBar to PostSearchFilters, add sort controls and brand filter, relocate station count display
- Move SearchBar.vue to PostSearchFilters.vue and expand to include sort buttons, brand filter dropdown, and station count
- Integrate sort controls (Reliable/Price/Distance/Updated) with icons into filter bar
- Add brand filter dropdown with dynamic brand list from parent, emit update events
- Move station count from StationList to PostSearchFilters, display as "X station(s) found"
- Remove sort tabs and brand filter from StationList component
- Add force-new-line div for mobile layout between Refine and Sort groups
- Include brand filter in hasActive check and resetFilters function
- Update Home.vue to pass brands/brandFilter props and handle brandFilter updates
- Add reset() method to useStations composable to clear state on empty query
- Clear search state when route query is empty instead of attempting search
- Update Fuel Finder API base URL to include /api/v1 path
- Adjust map zoom levels for 10-15 mile radius range
- Update API token request to use retry and increase timeout to 60s
2026-04-22 11:50:59 +01:00

157 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# External API & Data Sources
## UK Fuel Finder API (gov.uk) — PRIMARY SOURCE
- Base URL: `https://www.fuel-finder.service.gov.uk/api/v1/`
- Returns: all UK station prices + station metadata (~14,500 stations)
- Update frequency: stations report within 30 minutes of price change
- Our polling interval: every 30 minutes via scheduler (incremental using `effective-start-timestamp`), station metadata auto-refreshed once per day on the first poll after midnight
### Authentication
OAuth 2.0 using JSON body POST (not form-encoded). Credentials in `.env` as `FUEL_FINDER_CLIENT_ID` / `FUEL_FINDER_CLIENT_SECRET`.
**Get token:**
```
POST /api/v1/oauth/generate_access_token
Content-Type: application/json
{"client_id": "...", "client_secret": "..."}
Response: {"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "eyJ..."}
```
**Refresh token:**
```
POST /api/v1/oauth/regenerate_access_token
Content-Type: application/json
{"client_id": "...", "client_secret": "..."}
```
**Token caching strategy (FuelPriceService):**
- Cache the `access_token` with TTL = `expires_in` - 60 seconds (3540s)
- Cache key: `fuel_finder_access_token`
- On cache miss: call `generate_access_token`, store result, return token
- Use the `refresh_token` to regenerate before expiry if needed
- Include token in every API request: `Authorization: Bearer {token}`
#### Endpoints
- `GET /api/v1/pfs/fuel-prices?batch-number={n}` — all station prices (500 stations per batch)
- `GET /api/v1/pfs/fuel-prices?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental, only prices changed since timestamp
- `GET /api/v1/pfs?batch-number={n}` — all station metadata (500 per batch)
- `GET /api/v1/pfs?batch-number={n}&effective-start-timestamp=YYYY-MM-DD HH:MM:SS` — incremental station metadata
**Fuel prices response fields** (array of stations):
- `node_id`, `public_phone_number`, `trading_name` — station identifiers
- `fuel_prices[]` — array of `{fuel_type, price, price_last_updated, price_change_effective_timestamp}`
- Fuel types (API casing): `E5`, `E10`, `B7_Standard`, `B7_Premium`, `B10`, `HVO` — lowercased on ingest via `FuelType::fromApiValue()`
- Price is a float (e.g. `159.9` = 159.9p) — multiply × 100 and store as integer pence
**Station metadata response fields** (array of stations):
- `node_id`, `trading_name`, `brand_name`, `is_same_trading_and_brand_name`, `public_phone_number`
- `is_supermarket_service_station`, `is_motorway_service_station`
- `temporary_closure`, `permanent_closure`, `permanent_closure_date`
- `location``{address_line_1, address_line_2, city, county, country, postcode, latitude, longitude}`
- `amenities`**OBJECT** with boolean flags: `{adblue_pumps, adblue_packaged, lpg_pumps, car_wash, air_pump_or_screenwash, water_filling, twenty_four_hour_fuel, customer_toilets}`. Normalised at ingest to a flat array of enabled keys.
- `fuel_types`**OBJECT** with boolean flags: `{E10, E5, B7_Standard, B7_Premium, B10, HVO}`. Normalised at ingest to a flat array of enabled keys.
- `opening_times``usual_days.{monday..sunday}.{open, close, is_24_hours}` + `bank_holidays.type.{open_time, close_time, is_24_hours}`. Stored as raw JSON, not used in scoring.
### Required-field validation
Stations missing any of `node_id`, `trading_name`, `location.postcode`, `location.latitude`, `location.longitude` are dropped at ingest with a warning. Price rows missing any of `fuel_type`, `price`, `price_last_updated`, `price_change_effective_timestamp` are skipped silently.
### Incremental polling (FuelPriceService::pollPrices)
On each successful poll the wall-clock start time is cached under `fuel_finder_last_price_poll_at` (forever). The next poll sends this as `effective-start-timestamp`. Cold start (cache miss) performs a full fetch.
### FK safety
Price batches are filtered against the `stations` table before insert — any station not yet in `stations` is skipped and logged. This guards against new stations appearing in the prices endpoint before the next metadata refresh picks them up.
### FuelPriceService responsibilities
1. Fetch OAuth token (cache it)
2. GET all station prices
3. Upsert `stations` table with metadata
4. Insert new rows into `station_prices` only when price has changed for that station+fuel combo
5. Call StationTaggingService to set `is_supermarket` and `brand`
6. Dispatch `PricesUpdatedEvent` for downstream processing
### Deduplication
Only insert a new `station_prices` row if price differs from the most recent stored price
for that `(station_id, fuel_type)` combination. Avoids row explosion on unchanged prices.
### Credentials in .env
```
FUEL_FINDER_CLIENT_ID=
FUEL_FINDER_CLIENT_SECRET=
FUEL_FINDER_BASE_URL=https://www.fuel-finder.service.gov.uk/api/v1
```
## Postcodes.io — location resolution
- Free, no API key required
- Handled by `PostcodeService::resolve(string $query): ?LocationResult`
- Returns `LocationResult` DTO with `query`, `displayName`, `lat`, `lng`
- Results cached for 30 days — cache key `postcode:{normalised_input}`
- Failed lookups are NOT cached — retried on next request
- Input is auto-detected:
| Input type | Example | Endpoint |
|---|---|---|
| Full postcode | `SW1A 1AA` | `GET /postcodes/{postcode}` |
| Outcode (district) | `PE7` | `GET /outcodes/{outcode}` |
| Place / city name | `Manchester` | `GET /places?q={query}&limit=1` |
**Anonymous search flow:** user types a postcode/city → `PostcodeService::resolve()` → lat/lng stored in a JSON cookie (30 days) alongside the query string. On return visits, cookie lat/lng is used directly — postcodes.io is only called when the search term changes.
**Registered users:** postcode resolved once on registration, lat/lng stored on `users` table — not re-resolved unless postcode changes.
## FRED API (St. Louis Fed) — Brent crude prices
- Series: `DCOILBRENTEU` (daily Brent spot price, USD/barrel)
- Endpoint: `GET https://api.stlouisfed.org/fred/series/observations`
- Params: `series_id=DCOILBRENTEU`, `sort_order=desc`, `limit=30`, `file_type=json`
- Free API key required — stored as `FRED_API_KEY` in `.env`
- Handled by `OilPriceService::fetchBrentPrices()`
- Fetched daily at 7am via `oil:predict --fetch` scheduler command
- FRED uses `"."` as a placeholder for non-trading days (weekends/holidays) — filtered out before insert
- Stored in `brent_prices` table, upserted on `date` primary key
## Anthropic API — oil price direction prediction
- Endpoint: `POST https://api.anthropic.com/v1/messages`
- Model: `claude-haiku-4-5-20251001` (configurable via `ANTHROPIC_MODEL` in `.env`)
- Key stored as `ANTHROPIC_API_KEY` in `.env`
- Handled by `OilPriceService::generateLlmPrediction()`
- Called once daily after FRED fetch — sends last 30 days of Brent prices + pre-computed EWMA context
- Response must be JSON: `{"direction": "rising|falling|flat", "confidence": 0-85, "reasoning": "..."}`
- Model sometimes wraps JSON in markdown code fences — these are stripped before `json_decode`
- Confidence is capped at 85 regardless of what the model returns
- On any failure (API error, malformed JSON, invalid direction) → falls back to EWMA silently
- Result stored in `price_predictions` table with `source = 'llm'`
**EWMA fallback (`OilPriceService::generateEwmaPrediction()`):**
- Compares 3-day EWMA vs 7-day EWMA on chronological Brent price data
- Threshold: ±1.5% change → rising/falling; below → flat
- Confidence capped at 65 (simpler model)
- Used when: no `ANTHROPIC_API_KEY` set, or LLM call fails
- Result stored in `price_predictions` table with `source = 'ewma'`
## OneSignal — push notifications
- REST API: `https://oapi.onesignal.com/notifications`
- App ID + REST API key stored in .env as `ONESIGNAL_APP_ID`, `ONESIGNAL_API_KEY`
- Target by `player_id` (stored in `users.push_token`)
- No official Laravel package needed — use Laravel HTTP client (`Http::post(...)`)
- Free plan: 10,000 subscribers — sufficient for v1
## Vonage — WhatsApp + SMS
- Package: `vonage/client-core` via Composer
- Credentials: `VONAGE_KEY`, `VONAGE_SECRET`, `VONAGE_WHATSAPP_FROM` in .env
- WhatsApp: Messages API, utility template category (pre-approved)
- SMS: SMS API, alphanumeric sender ID "FuelAlert"
- All Vonage calls go through NotificationDispatchService — never call Vonage directly from components
## HTTP client
Use Laravel's built-in `Http` facade for all external API calls.
Always set a timeout: `Http::timeout(10)->get(...)`.
Wrap in try/catch — log failures, never let a failed API call crash the scheduler.