diff --git a/app/Filament/Resources/Searches/Pages/ListSearches.php b/app/Filament/Resources/Searches/Pages/ListSearches.php new file mode 100644 index 0000000..dc075f7 --- /dev/null +++ b/app/Filament/Resources/Searches/Pages/ListSearches.php @@ -0,0 +1,16 @@ +columns([ + TextColumn::make('searched_at') + ->label('Searched At') + ->dateTime('d M Y H:i') + ->sortable(), + TextColumn::make('fuel_type') + ->label('Fuel Type') + ->badge(), + TextColumn::make('results_count') + ->label('Results') + ->numeric() + ->sortable(), + TextColumn::make('lowest_pence') + ->label('Lowest') + ->formatStateUsing(fn (int $state): string => number_format($state / 100, 1).'p') + ->sortable(), + TextColumn::make('highest_pence') + ->label('Highest') + ->formatStateUsing(fn (int $state): string => number_format($state / 100, 1).'p') + ->sortable(), + TextColumn::make('avg_pence') + ->label('Average') + ->formatStateUsing(fn (string $state): string => number_format((float) $state / 100, 1).'p') + ->sortable(), + TextColumn::make('lat_bucket') + ->label('Area (lat/lng)') + ->formatStateUsing(fn (Search $record): string => $record->lat_bucket.', '.$record->lng_bucket) + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('ip_hash') + ->label('IP Hash') + ->limit(16) + ->toggleable(isToggledHiddenByDefault: true), + ]) + ->defaultSort('searched_at', 'desc') + ->filters([ + SelectFilter::make('fuel_type') + ->options([ + 'E10' => 'E10', + 'E5' => 'E5', + 'B7_STANDARD' => 'B7 Standard', + 'B7_PREMIUM' => 'B7 Premium', + 'B10' => 'B10', + 'HVO' => 'HVO', + ]), + ]) + ->recordActions([]) + ->toolbarActions([]); + } + + public static function getPages(): array + { + return [ + 'index' => ListSearches::route('/'), + ]; + } +} diff --git a/app/Filament/Widgets/StatsOverviewWidget.php b/app/Filament/Widgets/StatsOverviewWidget.php index 517c0e5..cbf185a 100644 --- a/app/Filament/Widgets/StatsOverviewWidget.php +++ b/app/Filament/Widgets/StatsOverviewWidget.php @@ -47,7 +47,7 @@ class StatsOverviewWidget extends BaseWidget private function oilPredictionStat(): Stat { - $prediction = PricePrediction::latest('generated_at')->first(); + $prediction = PricePrediction::bestFirst()->latest('generated_at')->first(); if ($prediction === null) { return Stat::make('Latest oil prediction', 'None') diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php new file mode 100644 index 0000000..d387fb0 --- /dev/null +++ b/app/Http/Controllers/Api/AuthController.php @@ -0,0 +1,60 @@ +validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'confirmed', Password::defaults()], + ]); + + $user = User::create($data); + $token = $user->createToken('api')->plainTextToken; + + return response()->json(['token' => $token, 'user' => $user], 201); + } + + public function login(Request $request): JsonResponse + { + $credentials = $request->validate([ + 'email' => ['required', 'email'], + 'password' => ['required', 'string'], + ]); + + if (! Auth::attempt($credentials)) { + return response()->json(['message' => 'Invalid credentials.'], 401); + } + + /** @var User $user */ + $user = Auth::user(); + $token = $user->createToken('api')->plainTextToken; + + return response()->json(['token' => $token, 'user' => $user]); + } + + public function logout(Request $request): JsonResponse + { + /** @var PersonalAccessToken $token */ + $token = $request->user()->currentAccessToken(); + $token->delete(); + + return response()->json(['message' => 'Logged out.']); + } + + public function me(Request $request): JsonResponse + { + return response()->json($request->user()); + } +} diff --git a/app/Http/Controllers/Api/StationController.php b/app/Http/Controllers/Api/StationController.php index a4e742e..40f058b 100644 --- a/app/Http/Controllers/Api/StationController.php +++ b/app/Http/Controllers/Api/StationController.php @@ -7,15 +7,30 @@ use App\Http\Requests\Api\NearbyStationsRequest; use App\Http\Resources\Api\StationResource; use App\Models\Search; use App\Models\Station; +use App\Services\PostcodeService; use Illuminate\Database\Query\JoinClause; use Illuminate\Http\JsonResponse; +use Illuminate\Validation\ValidationException; class StationController extends Controller { + public function __construct(private readonly PostcodeService $postcodeService) {} + public function index(NearbyStationsRequest $request): JsonResponse { - $lat = (float) $request->input('lat'); - $lng = (float) $request->input('lng'); + if ($request->filled('postcode')) { + $location = $this->postcodeService->resolve($request->string('postcode')->toString()); + + if ($location === null) { + throw ValidationException::withMessages(['postcode' => 'Postcode not found.']); + } + + $lat = $location->lat; + $lng = $location->lng; + } else { + $lat = (float) $request->input('lat'); + $lng = (float) $request->input('lng'); + } $fuelType = $request->fuelType(); $radius = $request->radius(); $sort = $request->sort(); @@ -23,7 +38,7 @@ class StationController extends Controller $all = Station::query() ->selectRaw( 'stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, - (6371 * acos(MAX(-1.0, MIN(1.0, + (6371 * acos(GREATEST(-1.0, LEAST(1.0, cos(radians(?)) * cos(radians(lat)) * cos(radians(lng) - radians(?)) + sin(radians(?)) * sin(radians(lat)) )))) AS distance_km', diff --git a/app/Http/Requests/Api/NearbyStationsRequest.php b/app/Http/Requests/Api/NearbyStationsRequest.php index 322c0c5..ca63822 100644 --- a/app/Http/Requests/Api/NearbyStationsRequest.php +++ b/app/Http/Requests/Api/NearbyStationsRequest.php @@ -15,8 +15,9 @@ class NearbyStationsRequest extends FormRequest public function rules(): array { return [ - 'lat' => ['required', 'numeric', 'between:-90,90'], - 'lng' => ['required', 'numeric', 'between:-180,180'], + 'postcode' => ['nullable', 'string', 'max:10'], + 'lat' => ['required_without:postcode', 'nullable', 'numeric', 'between:-90,90'], + 'lng' => ['required_without:postcode', 'nullable', 'numeric', 'between:-180,180'], 'fuel_type' => ['required', 'string'], 'radius' => ['nullable', 'numeric', 'between:0.1,50'], 'sort' => ['nullable', 'string', 'in:price,distance'], diff --git a/app/Models/PricePrediction.php b/app/Models/PricePrediction.php index e2d3102..31e6cc0 100644 --- a/app/Models/PricePrediction.php +++ b/app/Models/PricePrediction.php @@ -6,6 +6,7 @@ use App\Enums\PredictionSource; use App\Enums\TrendDirection; use Database\Factories\PricePredictionFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -18,6 +19,8 @@ use Illuminate\Support\Carbon; * @property int $confidence * @property string|null $reasoning * @property Carbon $generated_at + * + * @method static Builder bestFirst() */ #[Fillable(['predicted_for', 'source', 'direction', 'confidence', 'reasoning', 'generated_at'])] class PricePrediction extends Model @@ -37,4 +40,21 @@ class PricePrediction extends Model 'generated_at' => 'datetime', ]; } + + /** + * Order by source quality: llm_with_context → llm → ewma. + * Use this whenever reading the "best" prediction for a given date. + * + * @param Builder $query + * @return Builder + */ + public function scopeBestFirst(Builder $query): Builder + { + $priority = implode(', ', array_map( + fn (string $v) => "'$v'", + [PredictionSource::LlmWithContext->value, PredictionSource::Llm->value, PredictionSource::Ewma->value], + )); + + return $query->orderByRaw("FIELD(source, $priority)"); + } } diff --git a/app/Models/User.php b/app/Models/User.php index d080e39..cd7c0b5 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -13,13 +13,14 @@ use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Str; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; #[Fillable(['name', 'email', 'password', 'is_admin', 'postcode'])] #[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])] class User extends Authenticatable implements FilamentUser { /** @use HasFactory */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; /** * Get the attributes that should be cast. diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..5930b8c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -37,6 +37,13 @@ class AppServiceProvider extends ServiceProvider app()->isProduction(), ); + // SQLite lacks GREATEST/LEAST scalar functions — register them for tests. + if (DB::connection()->getDriverName() === 'sqlite') { + $pdo = DB::connection()->getPdo(); + $pdo->sqliteCreateFunction('GREATEST', fn (...$args) => max($args), -1); + $pdo->sqliteCreateFunction('LEAST', fn (...$args) => min($args), -1); + } + Password::defaults(fn (): ?Password => app()->isProduction() ? Password::min(12) ->mixedCase() diff --git a/app/Services/OilPriceService.php b/app/Services/OilPriceService.php index 40b0549..02c7d8b 100644 --- a/app/Services/OilPriceService.php +++ b/app/Services/OilPriceService.php @@ -86,8 +86,9 @@ class OilPriceService } /** - * Generate a prediction using LLM first, falling back to EWMA. - * Stores the result in price_predictions and returns it. + * Generate predictions from all available sources and store each one. + * EWMA always runs. LLM runs when an API key is configured. + * Returns the highest-confidence prediction (LLM preferred over EWMA). */ public function generatePrediction(): ?PricePrediction { @@ -101,20 +102,24 @@ class OilPriceService return null; } - $prediction = null; + $ewma = $this->generateEwmaPrediction($prices); + + if ($ewma !== null) { + PricePrediction::create($ewma->toArray()); + } + + $llm = null; if (config('services.anthropic.api_key')) { - $prediction = $this->generateLlmPredictionWithContext($prices); - $prediction ??= $this->generateLlmPrediction($prices); + $llm = $this->generateLlmPredictionWithContext($prices); + $llm ??= $this->generateLlmPrediction($prices); + + if ($llm !== null) { + PricePrediction::create($llm->toArray());personal_access_tokens + } } - $prediction ??= $this->generateEwmaPrediction($prices); - - if ($prediction !== null) { - PricePrediction::create($prediction->toArray()); - } - - return $prediction; + return $llm ?? $ewma; } /** @@ -171,9 +176,7 @@ class OilPriceService } $text = $response->json('content.0.text') ?? ''; - $text = preg_replace('/^```(?:json)?\s*/m', '', trim($text)); - $text = preg_replace('/```\s*$/m', '', $text); - $data = json_decode(trim($text), true); + $data = $this->extractJson($text); if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) { Log::error('OilPriceService: unexpected LLM response format', ['text' => $text]); @@ -237,10 +240,9 @@ class OilPriceService $url = 'https://api.anthropic.com/v1/messages'; $messages = [['role' => 'user', 'content' => $prompt]]; - $response = null; try { - for ($i = 0; $i < 5; $i++) { + for ($i = 0, $response = null; $i < 5; $i++) { $response = $this->apiLogger->send('anthropic', 'POST', $url, fn () => Http::timeout(30) ->withHeaders([ 'x-api-key' => config('services.anthropic.api_key'), @@ -249,12 +251,15 @@ class OilPriceService ->post($url, [ 'model' => config('services.anthropic.model', 'claude-sonnet-4-6'), 'max_tokens' => 1024, - 'tools' => [['type' => 'web_search_20260209', 'name' => 'web_search']], + 'tools' => [['type' => 'web_search_20250305', 'name' => 'web_search']], 'messages' => $messages, ])); if (! $response->successful()) { - Log::error('OilPriceService: Anthropic context request failed', ['status' => $response->status()]); + Log::error('OilPriceService: Anthropic context request failed', [ + 'status' => $response->status(), + 'body' => $response->body(), + ]); return null; } @@ -266,12 +271,13 @@ class OilPriceService $messages[] = ['role' => 'assistant', 'content' => $response->json('content')]; } - $text = collect($response->json('content') ?? []) - ->firstWhere('type', 'text')['text'] ?? ''; + $content = $response->json('content') ?? []; - $text = preg_replace('/^```(?:json)?\s*/m', '', trim($text)); - $text = preg_replace('/```\s*$/m', '', $text); - $data = json_decode(trim($text), true); + $text = collect($content) + ->filter(fn ($b) => ($b['type'] ?? '') === 'text') + ->implode('text', ''); + + $data = $this->extractJson($text); if (! isset($data['direction'], $data['confidence'], $data['reasoning'])) { Log::error('OilPriceService: unexpected context LLM response format', ['text' => $text]); @@ -366,6 +372,23 @@ class OilPriceService return round($ema, 4); } + /** + * Strip markdown code fences from a string and extract the first JSON object found. + * Handles prose preambles that Claude sometimes adds before the JSON. + */ + private function extractJson(string $text): ?array + { + $text = preg_replace('/^```(?:json)?\s*/m', '', trim($text)); + $text = preg_replace('/```\s*$/m', '', $text); + $start = strpos($text, '{'); + $end = strrpos($text, '}'); + if ($start === false || $end === false) { + return null; + } + + return json_decode(substr($text, $start, $end - $start + 1), true) ?: null; + } + /** * Map a % change magnitude to a 0–EWMA_MAX_CONFIDENCE confidence score. * 1.5% → ~30, 3% → ~50, 5%+ → 65. diff --git a/bootstrap/app.php b/bootstrap/app.php index c3928c5..6086def 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,6 +3,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\Exceptions; use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -15,5 +16,5 @@ return Application::configure(basePath: dirname(__DIR__)) // }) ->withExceptions(function (Exceptions $exceptions): void { - // + $exceptions->shouldRenderJsonWhen(fn (Request $request) => $request->is('api/*')); })->create(); diff --git a/routes/api.php b/routes/api.php index 7039e84..28c0be6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,6 +1,5 @@ group(function (): void { - Route::post('/register', [AuthController::class, 'register']); - Route::post('/login', [AuthController::class, 'login']); - Route::middleware('auth:sanctum')->group(function (): void { - Route::post('/logout', [AuthController::class, 'logout']); - Route::get('/me', [AuthController::class, 'me']); - }); -}); diff --git a/tests/Feature/Api/AuthControllerTest.php b/tests/Feature/Api/AuthControllerTest.php new file mode 100644 index 0000000..4095d35 --- /dev/null +++ b/tests/Feature/Api/AuthControllerTest.php @@ -0,0 +1,81 @@ +postJson('/api/auth/register', [ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]) + ->assertCreated() + ->assertJsonStructure(['token', 'user' => ['id', 'name', 'email']]); +}); + +it('returns 422 when register fields are missing', function () { + $this->postJson('/api/auth/register') + ->assertUnprocessable() + ->assertJsonValidationErrors(['name', 'email', 'password']); +}); + +it('returns 422 when email is already taken', function () { + User::factory()->create(['email' => 'taken@example.com']); + + $this->postJson('/api/auth/register', [ + 'name' => 'Another User', + 'email' => 'taken@example.com', + 'password' => 'password', + 'password_confirmation' => 'password', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['email']); +}); + +it('logs in with valid credentials and returns a token', function () { + $user = User::factory()->create(['password' => bcrypt('secret123')]); + + $this->postJson('/api/auth/login', [ + 'email' => $user->email, + 'password' => 'secret123', + ]) + ->assertOk() + ->assertJsonStructure(['token', 'user']); +}); + +it('returns 401 for invalid credentials', function () { + User::factory()->create(['email' => 'user@example.com', 'password' => bcrypt('correct')]); + + $this->postJson('/api/auth/login', [ + 'email' => 'user@example.com', + 'password' => 'wrong', + ])->assertUnauthorized(); +}); + +it('returns the authenticated user on /me', function () { + $user = User::factory()->create(); + + $this->actingAs($user, 'sanctum') + ->getJson('/api/auth/me') + ->assertOk() + ->assertJsonPath('email', $user->email); +}); + +it('logs out and revokes the token', function () { + $user = User::factory()->create(); + $token = $user->createToken('api')->plainTextToken; + + $this->withToken($token) + ->postJson('/api/auth/logout') + ->assertOk() + ->assertJsonPath('message', 'Logged out.'); + + expect($user->tokens()->count())->toBe(0); +}); + +it('returns 401 on protected routes without a token', function () { + $this->getJson('/api/auth/me')->assertUnauthorized(); +}); diff --git a/tests/Feature/Api/StationControllerTest.php b/tests/Feature/Api/StationControllerTest.php index 32156cc..f085008 100644 --- a/tests/Feature/Api/StationControllerTest.php +++ b/tests/Feature/Api/StationControllerTest.php @@ -4,6 +4,7 @@ use App\Enums\FuelType; use App\Models\Station; use App\Models\StationPriceCurrent; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Http; uses(RefreshDatabase::class); @@ -102,3 +103,53 @@ it('returns 422 when required params are missing', function () { $this->getJson('/api/stations?lat=52.5') ->assertUnprocessable(); }); + +it('resolves a full postcode to coordinates and returns nearby stations', function () { + $station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::E10, + 'price_pence' => 14200, + ]); + + Http::fake([ + 'api.postcodes.io/postcodes/SW1A1AA' => Http::response([ + 'status' => 200, + 'result' => ['postcode' => 'SW1A 1AA', 'latitude' => 51.5010, 'longitude' => -0.1415], + ]), + ]); + + $this->getJson('/api/stations?postcode=SW1A+1AA&fuel_type=e10&radius=1') + ->assertOk() + ->assertJsonPath('meta.count', 1); +}); + +it('resolves an outcode to coordinates', function () { + $station = Station::factory()->create(['lat' => 51.5010, 'lng' => -0.1415]); + StationPriceCurrent::factory()->create([ + 'station_id' => $station->node_id, + 'fuel_type' => FuelType::E10, + 'price_pence' => 14200, + ]); + + Http::fake([ + 'api.postcodes.io/outcodes/SW1A' => Http::response([ + 'status' => 200, + 'result' => ['outcode' => 'SW1A', 'latitude' => 51.5010, 'longitude' => -0.1415], + ]), + ]); + + $this->getJson('/api/stations?postcode=SW1A&fuel_type=e10&radius=1') + ->assertOk() + ->assertJsonPath('meta.count', 1); +}); + +it('returns 422 when postcode cannot be resolved', function () { + Http::fake([ + 'api.postcodes.io/*' => Http::response(['status' => 404, 'error' => 'Postcode not found'], 404), + ]); + + $this->getJson('/api/stations?postcode=ZZ99+9ZZ&fuel_type=e10') + ->assertUnprocessable() + ->assertJsonValidationErrors(['postcode']); +}); diff --git a/tests/Unit/Services/OilPriceServiceTest.php b/tests/Unit/Services/OilPriceServiceTest.php index 7b71bac..f9efb37 100644 --- a/tests/Unit/Services/OilPriceServiceTest.php +++ b/tests/Unit/Services/OilPriceServiceTest.php @@ -221,7 +221,7 @@ it('sends web_search tool in the context prediction request', function (): void Http::assertSent(function ($request) { $tools = $request->data()['tools'] ?? []; - return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20260209'); + return collect($tools)->contains(fn ($t) => $t['type'] === 'web_search_20250305'); }); }); @@ -299,7 +299,7 @@ it('uses LLM with context when API key is configured', function (): void { $prediction = $this->service->generatePrediction(); expect($prediction->source)->toBe(PredictionSource::LlmWithContext) - ->and(PricePrediction::count())->toBe(1); + ->and(PricePrediction::count())->toBe(2); }); it('falls back to plain LLM when context method fails', function (): void { @@ -323,7 +323,7 @@ it('falls back to plain LLM when context method fails', function (): void { $prediction = $this->service->generatePrediction(); expect($prediction->source)->toBe(PredictionSource::Llm) - ->and(PricePrediction::count())->toBe(1); + ->and(PricePrediction::count())->toBe(2); }); it('falls back to EWMA when both LLM methods fail', function (): void {