# Database ## Engine and conventions - MySQL with InnoDB. All schema changes via Laravel migrations only — no raw DDL. - All models use Eloquent. No raw DB:: queries except for complex aggregations. - Table names: plural snake_case. Column names: snake_case. - Always define `$fillable` or `$guarded` on models — never leave both empty. - Use `->comment()` on migration columns for non-obvious fields. ## Core tables ### users Standard Laravel users table + additions: - `postcode` VARCHAR(8) — user's home postcode, used for nearby station lookup - `lat` / `lng` DECIMAL(10,7) — resolved from postcode via postcodes.io on registration - `whatsapp_number` VARCHAR(20) NULLABLE — verified mobile, set after OTP flow - `whatsapp_verified_at` TIMESTAMP NULLABLE - `push_token` VARCHAR(255) NULLABLE — OneSignal player ID ### subscriptions Managed by Laravel Cashier — do not hand-edit this table. Tier is read via `$user->subscribed('basic')`, `->subscribed('plus')`, `->subscribed('pro')`. ### station_prices (HIGH VOLUME — partitioned) ``` id BIGINT UNSIGNED AUTO_INCREMENT station_id VARCHAR(64) — Fuel Finder API station identifier fuel_type ENUM('e10','e5','b7_standard','b7_premium','b10','hvo') price_pence SMALLINT UNSIGNED — e.g. 14523 = 145.23p (store as integer × 100) is_supermarket TINYINT(1) DEFAULT 0 brand VARCHAR(64) NULLABLE recorded_at DATETIME INDEX (station_id, fuel_type, recorded_at) INDEX (recorded_at) PARTITION BY RANGE (YEAR(recorded_at) * 100 + MONTH(recorded_at)) ``` Partition monthly. Use `recorded_at` not `created_at` — it reflects actual price time. Never store floats for prices — always pence as SMALLINT (price × 100). ### stations (reference/cache) ``` station_id VARCHAR(64) PRIMARY KEY — from Fuel Finder API name VARCHAR(128) brand VARCHAR(64) NULLABLE lat DECIMAL(10,7) lng DECIMAL(10,7) postcode VARCHAR(8) is_supermarket TINYINT(1) DEFAULT 0 amenities JSON NULLABLE last_seen_at DATETIME ``` Refreshed on each Fuel Finder poll. `is_supermarket` set by StationTaggingService. ### alerts (sent notification log) ``` id BIGINT UNSIGNED AUTO_INCREMENT user_id BIGINT UNSIGNED FK users.id alert_type ENUM('fill_up_now','wait','watchdog','price_drop') channel ENUM('email','whatsapp','sms','push') signal_strength TINYINT — 1=weak, 2=medium, 3=strong payload JSON — recommendation data snapshot sent_at DATETIME INDEX (user_id, sent_at) ``` ### scoring_results (daily per-user cache) ``` user_id BIGINT UNSIGNED FK users.id scored_at DATE recommendation ENUM('fill_up','wait','no_signal') confidence TINYINT — 0–100 signals JSON — breakdown of each signal used local_avg_pence SMALLINT UNSIGNED trend_direction ENUM('falling','rising','flat') expires_at DATETIME PRIMARY KEY (user_id, scored_at) ``` ### otp_verifications ``` id BIGINT UNSIGNED AUTO_INCREMENT user_id BIGINT UNSIGNED FK users.id channel ENUM('whatsapp','sms') phone_number VARCHAR(20) code CHAR(6) expires_at DATETIME used_at DATETIME NULLABLE INDEX (user_id, expires_at) ``` OTP codes expire after 10 minutes. Mark `used_at` on success — never delete rows. ### brent_prices (daily Brent crude from FRED) ``` date DATE PRIMARY KEY — trading day (no weekends/holidays) price_usd DECIMAL(8,2) — spot price USD per barrel ``` Populated daily by `OilPriceService::fetchBrentPrices()` via FRED API. FRED returns `"."` for non-trading days — these are filtered out before insert. Upserted on refetch so duplicate dates never occur. ### price_predictions (oil price direction forecast) ``` id BIGINT UNSIGNED AUTO_INCREMENT predicted_for DATE — the date this prediction covers source ENUM('llm','ewma') — which method generated it direction ENUM('rising','falling','flat') confidence TINYINT — 0–100 (LLM max 85, EWMA max 65) reasoning TEXT NULLABLE — plain-English explanation generated_at DATETIME INDEX (predicted_for, source) ``` Generated daily by `OilPriceService::generatePrediction()`. LLM (Anthropic) is tried first; EWMA is used as fallback if LLM fails or key not set. Signal 4 in AlertScoringService reads from this table — never from brent_prices directly. ## Supermarket brands (StationTaggingService) Match station `name` (case-insensitive) against: `['tesco', 'asda', 'morrisons', 'sainsbury', 'aldi', 'lidl', 'costco']` Set `is_supermarket = 1` and normalise `brand` on match.