Files
fuel-price/.claude/rules/database.md
Ovidiu U d5fb7f85bd
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
feat: add Filament admin panel with migrations and design spec
- Add AdminPanelProvider mounting panel at `/admin` with `is_admin` auth guard
- Add `is_admin` boolean column to users table
- Add brent_prices and price_predictions tables with appropriate indexes
- Add comprehensive admin design spec covering resources, dashboard, navigation, and build order
- Configure default panel with amber primary color and standard middleware stack
- Add compiled Filament assets (actions.js, app.css)
2026-04-04 13:40:56 +01:00

124 lines
4.6 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.

# 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 — 0100
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 — 0100 (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.