Files
fuel-price/.claude/rules/database.md
Ovidiu U 5ad89e977d 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>
2026-04-03 18:09:50 +01:00

3.5 KiB
Raw Blame History

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.

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.