From 895d55439bbc2772a7440e4ff78955217ffb657a Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Thu, 30 Apr 2026 08:20:23 +0100 Subject: [PATCH] refactor: extract StationSearchService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit item #8. StationController::index was ~100 lines doing the haversine join, in-PHP filter/sort/group, search-row write, and prediction call — well past the "thin orchestrator" line in .claude/rules/architecture.md. - App\Services\StationSearch\SearchCriteria — DTO (lat/lng/fuelType/ radiusKm/sort) - App\Services\StationSearch\SearchResult — DTO (stations, prices summary, reliability counts, prediction payload) - App\Services\StationSearch\StationSearchService::search(criteria, ?user, ?ipHash) — owns the haversine query, the per-row reliability memoisation, sort, count, search-row logging, and the tier-gated prediction. The controller now resolves coordinates, builds a SearchCriteria, calls the service, and shapes the JSON response. Down from 154 → 71 lines. Public API contract unchanged — all 15 StationController tests pass without modification. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Api/StationController.php | 165 +++++------------- app/Services/StationSearch/SearchCriteria.php | 16 ++ app/Services/StationSearch/SearchResult.php | 21 +++ .../StationSearch/StationSearchService.php | 151 ++++++++++++++++ 4 files changed, 230 insertions(+), 123 deletions(-) create mode 100644 app/Services/StationSearch/SearchCriteria.php create mode 100644 app/Services/StationSearch/SearchResult.php create mode 100644 app/Services/StationSearch/StationSearchService.php diff --git a/app/Http/Controllers/Api/StationController.php b/app/Http/Controllers/Api/StationController.php index 9cba67d..6b9e44e 100644 --- a/app/Http/Controllers/Api/StationController.php +++ b/app/Http/Controllers/Api/StationController.php @@ -2,31 +2,60 @@ namespace App\Http\Controllers\Api; -use App\Enums\PriceClassification; -use App\Enums\PriceReliability; use App\Http\Controllers\Controller; use App\Http\Requests\Api\NearbyStationsRequest; use App\Http\Resources\Api\StationResource; -use App\Models\Search; -use App\Models\Station; -use App\Models\User; -use App\Services\HaversineQuery; -use App\Services\NationalFuelPredictionService; -use App\Services\PlanFeatures; use App\Services\PostcodeService; -use Illuminate\Database\Query\JoinClause; +use App\Services\StationSearch\SearchCriteria; +use App\Services\StationSearch\StationSearchService; use Illuminate\Http\JsonResponse; -use Illuminate\Support\Carbon; use Illuminate\Validation\ValidationException; class StationController extends Controller { public function __construct( private readonly PostcodeService $postcodeService, - private readonly NationalFuelPredictionService $predictionService, + private readonly StationSearchService $searchService, ) {} public function index(NearbyStationsRequest $request): JsonResponse + { + [$lat, $lng] = $this->resolveCoordinates($request); + + $criteria = new SearchCriteria( + lat: $lat, + lng: $lng, + fuelType: $request->fuelType(), + radiusKm: $request->radius(), + sort: $request->sort(), + ); + + $result = $this->searchService->search( + $criteria, + $request->user(), + hash('sha256', $request->ip() ?? ''), + ); + + return response()->json([ + 'data' => StationResource::collection($result->stations), + 'meta' => [ + 'count' => $result->stations->count(), + 'fuel_type' => $criteria->fuelType->value, + 'radius_km' => $criteria->radiusKm, + 'lat' => $criteria->lat, + 'lng' => $criteria->lng, + 'lowest_pence' => $result->pricesSummary['lowest'], + 'highest_pence' => $result->pricesSummary['highest'], + 'cheapest_price_pence' => $result->pricesSummary['lowest'], + 'avg_pence' => $result->pricesSummary['avg'], + 'reliability_counts' => $result->reliabilityCounts, + ], + 'prediction' => $result->prediction, + ]); + } + + /** @return array{0: float, 1: float} */ + private function resolveCoordinates(NearbyStationsRequest $request): array { if ($request->filled('postcode')) { $location = $this->postcodeService->resolve($request->string('postcode')->toString()); @@ -35,119 +64,9 @@ class StationController extends Controller 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(); - - [$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($lat, $lng); - - $all = Station::query() - ->selectRaw( - "stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km", - $distanceBindings, - ) - ->join('station_prices_current as spc', function (JoinClause $join) use ($fuelType): void { - $join->on('stations.node_id', '=', 'spc.station_id') - ->where('spc.fuel_type', '=', $fuelType->value); - }) - ->where('stations.temporary_closure', false) - ->where('stations.permanent_closure', false) - ->get(); - - // Compute reliability + classification once per row. The resource and - // sort/groupBy below all read these cached attributes — without this, - // PriceReliability::fromUpdatedAt is invoked ~10× per station per - // response (sort comparator, count groupBy, resource value+label). - $all->each(function ($s): void { - $updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null; - $s->_updated_at = $updatedAt; - $s->_reliability = PriceReliability::fromUpdatedAt($updatedAt); - $s->_classification = PriceClassification::fromUpdatedAt($updatedAt); - }); - - $filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $radius); - - $stations = $sort === 'reliable' - ? $filtered - ->sort(function ($a, $b) { - return $a->_reliability->weight() <=> $b->_reliability->weight() - ?: ((int) $a->price_pence <=> (int) $b->price_pence) - ?: ((float) $a->distance_km <=> (float) $b->distance_km); - }) - ->values() - : $filtered->sortBy(match ($sort) { - 'price' => fn ($s) => (int) $s->price_pence, - 'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX, - default => fn ($s) => (float) $s->distance_km, - })->values(); - - $prices = $stations->pluck('price_pence'); - - $reliabilityCounts = $stations - ->groupBy(fn ($s) => $s->_reliability->value) - ->map->count(); - - Search::create([ - 'lat_bucket' => round($lat, 2), - 'lng_bucket' => round($lng, 2), - 'fuel_type' => $fuelType->value, - 'results_count' => $stations->count(), - 'lowest_pence' => $prices->min(), - 'highest_pence' => $prices->max(), - 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, - 'searched_at' => now(), - 'ip_hash' => hash('sha256', $request->ip() ?? ''), - ]); - - return response()->json([ - 'data' => StationResource::collection($stations), - 'meta' => [ - 'count' => $stations->count(), - 'fuel_type' => $fuelType->value, - 'radius_km' => $radius, - 'lat' => $lat, - 'lng' => $lng, - 'lowest_pence' => $prices->min(), - 'highest_pence' => $prices->max(), - 'cheapest_price_pence' => $prices->min(), - 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, - 'reliability_counts' => [ - 'reliable' => (int) $reliabilityCounts->get(PriceReliability::Reliable->value, 0), - 'stale' => (int) $reliabilityCounts->get(PriceReliability::Stale->value, 0), - 'outdated' => (int) $reliabilityCounts->get(PriceReliability::Outdated->value, 0), - ], - ], - 'prediction' => $this->predictionFor($request->user(), $lat, $lng), - ]); - } - - /** - * Returns the prediction payload for embedding in the search response. - * Free/guest users get a stripped teaser; users with the ai_predictions - * feature get the full multi-signal payload. - * - * @return array - */ - private function predictionFor(?User $user, float $lat, float $lng): array - { - $result = $this->predictionService->predict($lat, $lng); - - $canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions'); - - if (! $canSeeFull) { - return [ - 'fuel_type' => $result['fuel_type'], - 'predicted_direction' => $result['predicted_direction'], - 'tier_locked' => true, - ]; + return [$location->lat, $location->lng]; } - return $result; + return [(float) $request->input('lat'), (float) $request->input('lng')]; } } diff --git a/app/Services/StationSearch/SearchCriteria.php b/app/Services/StationSearch/SearchCriteria.php new file mode 100644 index 0000000..2dd53cf --- /dev/null +++ b/app/Services/StationSearch/SearchCriteria.php @@ -0,0 +1,16 @@ + $stations Sorted station rows with _updated_at/_reliability/_classification cached + * @param array{lowest: ?int, highest: ?int, avg: ?float} $pricesSummary + * @param array{reliable: int, stale: int, outdated: int} $reliabilityCounts + * @param array $prediction + */ + public function __construct( + public Collection $stations, + public array $pricesSummary, + public array $reliabilityCounts, + public array $prediction, + ) {} +} diff --git a/app/Services/StationSearch/StationSearchService.php b/app/Services/StationSearch/StationSearchService.php new file mode 100644 index 0000000..42c38d7 --- /dev/null +++ b/app/Services/StationSearch/StationSearchService.php @@ -0,0 +1,151 @@ +fetchAndSortStations($criteria); + $prices = $stations->pluck('price_pence'); + + $this->logSearch($criteria, $stations->count(), $prices, $ipHash); + + return new SearchResult( + stations: $stations, + pricesSummary: [ + 'lowest' => $prices->min(), + 'highest' => $prices->max(), + 'avg' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + ], + reliabilityCounts: $this->countReliability($stations), + prediction: $this->buildPrediction($user, $criteria), + ); + } + + /** @return Collection */ + private function fetchAndSortStations(SearchCriteria $criteria): Collection + { + [$distanceSql, $distanceBindings] = HaversineQuery::distanceKm($criteria->lat, $criteria->lng); + + $all = Station::query() + ->selectRaw( + "stations.*, spc.price_pence, spc.fuel_type, spc.price_effective_at, {$distanceSql} AS distance_km", + $distanceBindings, + ) + ->join('station_prices_current as spc', function (JoinClause $join) use ($criteria): void { + $join->on('stations.node_id', '=', 'spc.station_id') + ->where('spc.fuel_type', '=', $criteria->fuelType->value); + }) + ->where('stations.temporary_closure', false) + ->where('stations.permanent_closure', false) + ->get(); + + // Compute reliability + classification once per row so the sort, the + // count groupBy, and the StationResource render all read cached + // values instead of re-invoking PriceReliability::fromUpdatedAt. + $all->each(function ($s): void { + $updatedAt = $s->price_effective_at ? Carbon::parse($s->price_effective_at) : null; + $s->_updated_at = $updatedAt; + $s->_reliability = PriceReliability::fromUpdatedAt($updatedAt); + $s->_classification = PriceClassification::fromUpdatedAt($updatedAt); + }); + + $filtered = $all->filter(fn ($s) => (float) $s->distance_km <= $criteria->radiusKm); + + return $this->applySort($filtered, $criteria->sort); + } + + /** + * @param Collection $filtered + * @return Collection + */ + private function applySort(Collection $filtered, string $sort): Collection + { + if ($sort === 'reliable') { + return $filtered + ->sort(function ($a, $b) { + return $a->_reliability->weight() <=> $b->_reliability->weight() + ?: ((int) $a->price_pence <=> (int) $b->price_pence) + ?: ((float) $a->distance_km <=> (float) $b->distance_km); + }) + ->values(); + } + + return $filtered->sortBy(match ($sort) { + 'price' => fn ($s) => (int) $s->price_pence, + 'updated' => fn ($s) => $s->price_effective_at ? -strtotime($s->price_effective_at) : PHP_INT_MAX, + default => fn ($s) => (float) $s->distance_km, + })->values(); + } + + /** + * @param Collection $stations + * @return array{reliable: int, stale: int, outdated: int} + */ + private function countReliability(Collection $stations): array + { + $counts = $stations->groupBy(fn ($s) => $s->_reliability->value)->map->count(); + + return [ + 'reliable' => (int) $counts->get(PriceReliability::Reliable->value, 0), + 'stale' => (int) $counts->get(PriceReliability::Stale->value, 0), + 'outdated' => (int) $counts->get(PriceReliability::Outdated->value, 0), + ]; + } + + /** @param Collection $prices */ + private function logSearch(SearchCriteria $criteria, int $resultsCount, Collection $prices, ?string $ipHash): void + { + Search::create([ + 'lat_bucket' => round($criteria->lat, 2), + 'lng_bucket' => round($criteria->lng, 2), + 'fuel_type' => $criteria->fuelType->value, + 'results_count' => $resultsCount, + 'lowest_pence' => $prices->min(), + 'highest_pence' => $prices->max(), + 'avg_pence' => $prices->isNotEmpty() ? round($prices->avg(), 2) : null, + 'searched_at' => now(), + 'ip_hash' => $ipHash ?? hash('sha256', ''), + ]); + } + + /** + * Free/guest users get a stripped teaser; users with the ai_predictions + * feature get the full multi-signal payload. + * + * @return array + */ + private function buildPrediction(?User $user, SearchCriteria $criteria): array + { + $result = $this->predictionService->predict($criteria->lat, $criteria->lng); + + $canSeeFull = $user !== null && PlanFeatures::for($user)->can('ai_predictions'); + + if (! $canSeeFull) { + return [ + 'fuel_type' => $result['fuel_type'], + 'predicted_direction' => $result['predicted_direction'], + 'tier_locked' => true, + ]; + } + + return $result; + } +}