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) <noreply@anthropic.com>
73 lines
2.4 KiB
PHP
73 lines
2.4 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Http\Requests\Api\NearbyStationsRequest;
|
|
use App\Http\Resources\Api\StationResource;
|
|
use App\Services\PostcodeService;
|
|
use App\Services\StationSearch\SearchCriteria;
|
|
use App\Services\StationSearch\StationSearchService;
|
|
use Illuminate\Http\JsonResponse;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class StationController extends Controller
|
|
{
|
|
public function __construct(
|
|
private readonly PostcodeService $postcodeService,
|
|
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());
|
|
|
|
if ($location === null) {
|
|
throw ValidationException::withMessages(['postcode' => 'Postcode not found.']);
|
|
}
|
|
|
|
return [$location->lat, $location->lng];
|
|
}
|
|
|
|
return [(float) $request->input('lat'), (float) $request->input('lng')];
|
|
}
|
|
}
|