Add fuel API ingestion and historic storage design spec
Includes verified API authentication flow, correct base URL, all DB table schemas for stations, current prices, history, and archive. Fuel types corrected to match live API (B7_STANDARD, B7_PREMIUM). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
98
.claude/rules/database.md
Normal file
98
.claude/rules/database.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# 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.
|
||||
|
||||
## 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.
|
||||
Reference in New Issue
Block a user