- 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)
124 lines
4.6 KiB
Markdown
124 lines
4.6 KiB
Markdown
# 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.
|