Files
fuel-price/database.md
Ovidiu U 02d4c9d888
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
Add comprehensive project documentation and architecture guidelines
Establishes core rules and conventions for the FuelAlert Laravel application: architecture patterns (fat services, thin controllers), database schema with partitioned station_prices table, multi-tier notification system with Vonage and OneSignal, 4-signal scoring engine for fuel price recommendations, Stripe subscription tiers, Livewire classic component structure, and Pest testing standards.
2026-04-03 16:49:19 +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','diesel','super_diesel','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.