From ea22387c9d4425d02e1854640ef7abeeb984573e Mon Sep 17 00:00:00 2001 From: Ovidiu U Date: Fri, 12 Jun 2026 09:05:03 +0100 Subject: [PATCH] Reverse-geocode a general area label for logged searches Each search now stores an `area_label` (district/town) reverse-geocoded from its coarsened ~1km lat/lng bucket via postcodes.io, surfaced in the Filament Searches admin as a sortable/searchable column plus an area filter. Geocoding is cached 30 days per bucket, queries a 2km radius so low-density buckets still match the default 100m miss, and fails gracefully to null. Adds `searches:backfill-areas` (scheduled hourly) to label existing rows and retry stragglers. Co-Authored-By: Claude Opus 4.8 --- app/Console/Commands/BackfillSearchAreas.php | 54 ++++++++++++++ .../Resources/Searches/SearchResource.php | 14 ++++ app/Models/Search.php | 2 +- app/Services/PostcodeService.php | 71 ++++++++++++++++++ .../StationSearch/StationSearchService.php | 3 + ...73728_add_area_label_to_searches_table.php | 31 ++++++++ routes/console.php | 10 +++ tests/Feature/Api/StationControllerTest.php | 24 ++++++ .../Console/BackfillSearchAreasTest.php | 50 +++++++++++++ tests/Unit/Services/PostcodeServiceTest.php | 73 +++++++++++++++++++ 10 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 app/Console/Commands/BackfillSearchAreas.php create mode 100644 database/migrations/2026_06_12_073728_add_area_label_to_searches_table.php create mode 100644 tests/Feature/Console/BackfillSearchAreasTest.php diff --git a/app/Console/Commands/BackfillSearchAreas.php b/app/Console/Commands/BackfillSearchAreas.php new file mode 100644 index 0000000..2dc0eea --- /dev/null +++ b/app/Console/Commands/BackfillSearchAreas.php @@ -0,0 +1,54 @@ +option('limit'); + + $buckets = Search::query() + ->whereNull('area_label') + ->select('lat_bucket', 'lng_bucket') + ->distinct() + ->when($limit > 0, fn ($query) => $query->limit($limit)) + ->get(); + + if ($buckets->isEmpty()) { + $this->info('No searches need an area label.'); + + return self::SUCCESS; + } + + $resolved = 0; + $rowsUpdated = 0; + + foreach ($buckets as $bucket) { + $label = $postcodes->reverseResolve((float) $bucket->lat_bucket, (float) $bucket->lng_bucket); + + if ($label === null) { + continue; + } + + $resolved++; + $rowsUpdated += Search::query() + ->whereNull('area_label') + ->where('lat_bucket', $bucket->lat_bucket) + ->where('lng_bucket', $bucket->lng_bucket) + ->update(['area_label' => $label]); + } + + $this->info("Resolved {$resolved} of {$buckets->count()} areas, updated {$rowsUpdated} search rows."); + + return self::SUCCESS; + } +} diff --git a/app/Filament/Resources/Searches/SearchResource.php b/app/Filament/Resources/Searches/SearchResource.php index ee190cf..3709b92 100644 --- a/app/Filament/Resources/Searches/SearchResource.php +++ b/app/Filament/Resources/Searches/SearchResource.php @@ -28,6 +28,11 @@ class SearchResource extends Resource ->label('Searched At') ->dateTime('d M Y H:i') ->sortable(), + TextColumn::make('area_label') + ->label('Area') + ->placeholder('Unknown') + ->searchable() + ->sortable(), TextColumn::make('fuel_type') ->label('Fuel Type') ->badge(), @@ -67,6 +72,15 @@ class SearchResource extends Resource 'B10' => 'B10', 'HVO' => 'HVO', ]), + SelectFilter::make('area_label') + ->label('Area') + ->searchable() + ->options(fn (): array => Search::query() + ->whereNotNull('area_label') + ->distinct() + ->orderBy('area_label') + ->pluck('area_label', 'area_label') + ->all()), ]) ->recordActions([]) ->toolbarActions([]); diff --git a/app/Models/Search.php b/app/Models/Search.php index e46e96f..266fc44 100644 --- a/app/Models/Search.php +++ b/app/Models/Search.php @@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; -#[Fillable(['lat_bucket', 'lng_bucket', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])] +#[Fillable(['lat_bucket', 'lng_bucket', 'area_label', 'fuel_type', 'results_count', 'lowest_pence', 'highest_pence', 'avg_pence', 'searched_at', 'ip_hash'])] class Search extends Model { /** @use HasFactory */ diff --git a/app/Services/PostcodeService.php b/app/Services/PostcodeService.php index 8cc1c93..5d1284f 100644 --- a/app/Services/PostcodeService.php +++ b/app/Services/PostcodeService.php @@ -52,6 +52,77 @@ class PostcodeService return $result; } + /** + * Reverse-geocode coordinates to a general UK area label (e.g. "Peterborough"). + * + * Coordinates are bucketed to ~1km (2dp) before lookup so the cache is shared + * across nearby searches and nothing more precise than the stored bucket is + * ever queried. Returns null if the area cannot be determined. + */ + public function reverseResolve(float $lat, float $lng): ?string + { + $latBucket = round($lat, 2); + $lngBucket = round($lng, 2); + $cacheKey = "revgeo:{$latBucket},{$lngBucket}"; + + $cached = Cache::get($cacheKey); + + if (is_string($cached)) { + return $cached; + } + + $label = $this->lookupArea($latBucket, $lngBucket); + + if ($label !== null) { + Cache::put($cacheKey, $label, self::CACHE_TTL); + } + + return $label; + } + + private function lookupArea(float $lat, float $lng): ?string + { + $url = self::BASE_URL.'/postcodes'; + // radius=2000 (postcodes.io max): we query the ~1km bucket centroid, which + // can sit up to ~780m from any real point in the bucket. The default 100m + // radius misses in low-density areas, so widen it to guarantee a hit. + $logUrl = $url.'?lon='.$lng.'&lat='.$lat.'&radius=2000&limit=1'; + + try { + $response = $this->apiLogger->send('postcodes_io', 'GET', $logUrl, fn () => Http::timeout(5) + ->get($url, ['lon' => $lng, 'lat' => $lat, 'radius' => 2000, 'limit' => 1])); + + if (! $response->successful()) { + return null; + } + + $results = $response->json('result'); + + if (! is_array($results) || ! isset($results[0]) || ! is_array($results[0])) { + return null; + } + + // Prefer the most human "town/district" field, falling back to broader areas. + foreach (['admin_district', 'parish', 'admin_ward', 'region', 'country'] as $field) { + $value = $results[0][$field] ?? null; + + if (is_string($value) && $value !== '') { + return $value; + } + } + + return null; + } catch (Throwable $e) { + Log::error('PostcodeService: reverse geocode failed', [ + 'lat' => $lat, + 'lng' => $lng, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + private function normalisePostcode(string $value): string { return strtoupper(preg_replace('/\s+/', '', $value)); diff --git a/app/Services/StationSearch/StationSearchService.php b/app/Services/StationSearch/StationSearchService.php index ed57dfc..1b96bd9 100644 --- a/app/Services/StationSearch/StationSearchService.php +++ b/app/Services/StationSearch/StationSearchService.php @@ -11,6 +11,7 @@ use App\Services\Forecasting\LocalSnapshotService; use App\Services\Forecasting\WeeklyForecastService; use App\Services\HaversineQuery; use App\Services\PlanFeatures; +use App\Services\PostcodeService; use Illuminate\Database\Query\JoinClause; use Illuminate\Support\Carbon; use Illuminate\Support\Collection; @@ -20,6 +21,7 @@ final class StationSearchService public function __construct( private readonly WeeklyForecastService $weeklyForecast, private readonly LocalSnapshotService $localSnapshot, + private readonly PostcodeService $postcodeService, ) {} public function search(SearchCriteria $criteria, ?User $user, ?string $ipHash): SearchResult @@ -118,6 +120,7 @@ final class StationSearchService Search::create([ 'lat_bucket' => round($criteria->lat, 2), 'lng_bucket' => round($criteria->lng, 2), + 'area_label' => $this->postcodeService->reverseResolve($criteria->lat, $criteria->lng), 'fuel_type' => $criteria->fuelType->value, 'results_count' => $resultsCount, 'lowest_pence' => $prices->min(), diff --git a/database/migrations/2026_06_12_073728_add_area_label_to_searches_table.php b/database/migrations/2026_06_12_073728_add_area_label_to_searches_table.php new file mode 100644 index 0000000..8951d92 --- /dev/null +++ b/database/migrations/2026_06_12_073728_add_area_label_to_searches_table.php @@ -0,0 +1,31 @@ +string('area_label', 100) + ->nullable() + ->after('lng_bucket') + ->comment('General UK area (e.g. district/town) reverse-geocoded from the lat/lng bucket'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('searches', function (Blueprint $table): void { + $table->dropColumn('area_label'); + }); + } +}; diff --git a/routes/console.php b/routes/console.php index 8354a0e..0bf29e6 100644 --- a/routes/console.php +++ b/routes/console.php @@ -77,6 +77,16 @@ Schedule::command('fuel:archive') ->onOneServer() ->runInBackground(); +// Retry area labels that failed to reverse-geocode at search time (transient +// postcodes.io blip, or a genuinely remote point). Searches normally get their +// area_label inline; this just mops up stragglers. Cached per bucket, so it +// only calls the API for buckets it hasn't resolved yet. +Schedule::command('searches:backfill-areas') + ->hourly() + ->withoutOverlapping() + ->onOneServer() + ->runInBackground(); + // Scheduled WhatsApp updates — morning and evening Schedule::job(new SendScheduledWhatsAppJob('morning'))->dailyAt('07:30')->onOneServer(); Schedule::job(new SendScheduledWhatsAppJob('evening'))->dailyAt('18:00')->onOneServer(); diff --git a/tests/Feature/Api/StationControllerTest.php b/tests/Feature/Api/StationControllerTest.php index 86dcfd0..ef411f4 100644 --- a/tests/Feature/Api/StationControllerTest.php +++ b/tests/Feature/Api/StationControllerTest.php @@ -12,6 +12,17 @@ uses(RefreshDatabase::class); beforeEach(function () { $this->withHeaders(['X-Api-Key' => config('app.api_secret_key')]); + + // Every search reverse-geocodes its lat/lng bucket to an area label. Fake the + // postcodes.io reverse endpoint (query-string form) so tests never hit the + // network; the path form (/postcodes/SW1A1AA) used for forward lookups is + // matched by per-test stubs and is unaffected by this. + Http::fake([ + 'api.postcodes.io/postcodes?*' => Http::response([ + 'status' => 200, + 'result' => [['admin_district' => 'Testshire']], + ]), + ]); }); function asPaidUserOnStations(string $tier = 'plus'): User @@ -114,6 +125,19 @@ it('logs a search record for each request', function () { ]); }); +it('stores the reverse-geocoded area label on the search record', function () { + $station = Station::factory()->create(['lat' => 52.555064, 'lng' => -0.256119]); + StationPriceCurrent::factory()->create(['station_id' => $station->node_id, 'fuel_type' => FuelType::B7Standard, 'price_pence' => 14500]); + + $this->getJson('/api/stations?lat=52.555064&lng=-0.256119&fuel_type=b7_standard&radius=10'); + + $this->assertDatabaseHas('searches', [ + 'lat_bucket' => '52.56', + 'lng_bucket' => '-0.26', + 'area_label' => 'Testshire', + ]); +}); + it('returns 422 when required params are missing', function () { $this->getJson('/api/stations?lat=52.5') ->assertUnprocessable(); diff --git a/tests/Feature/Console/BackfillSearchAreasTest.php b/tests/Feature/Console/BackfillSearchAreasTest.php new file mode 100644 index 0000000..0826d41 --- /dev/null +++ b/tests/Feature/Console/BackfillSearchAreasTest.php @@ -0,0 +1,50 @@ + Http::response([ + 'status' => 200, + 'result' => [['admin_district' => 'Peterborough']], + ]), + ]); + + // Two searches share a bucket; a third is in a different bucket. + Search::factory()->count(2)->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => null]); + Search::factory()->create(['lat_bucket' => 51.50, 'lng_bucket' => -0.14, 'area_label' => null]); + + $this->artisan('searches:backfill-areas')->assertSuccessful(); + + expect(Search::whereNull('area_label')->count())->toBe(0) + ->and(Search::where('area_label', 'Peterborough')->count())->toBe(3); + + // One reverse-geocode call per distinct bucket, not per row. + Http::assertSentCount(2); +}); + +it('leaves searches that already have an area label untouched', function () { + Http::fake([ + '*/postcodes?*' => Http::response([ + 'status' => 200, + 'result' => [['admin_district' => 'Peterborough']], + ]), + ]); + + Search::factory()->create(['lat_bucket' => 52.54, 'lng_bucket' => -0.21, 'area_label' => 'Manchester']); + + $this->artisan('searches:backfill-areas')->assertSuccessful(); + + expect(Search::where('area_label', 'Manchester')->count())->toBe(1); + Http::assertNothingSent(); +}); + +it('reports when there is nothing to backfill', function () { + $this->artisan('searches:backfill-areas') + ->expectsOutputToContain('No searches need an area label.') + ->assertSuccessful(); +}); diff --git a/tests/Unit/Services/PostcodeServiceTest.php b/tests/Unit/Services/PostcodeServiceTest.php index 9e99cfc..e45b299 100644 --- a/tests/Unit/Services/PostcodeServiceTest.php +++ b/tests/Unit/Services/PostcodeServiceTest.php @@ -279,3 +279,76 @@ it('persists an outcode resolved via HTTP fallback', function (): void { ->and((float) $row->lat)->toBe(52.536397) ->and((float) $row->lng)->toBe(-0.210181); }); + +// --- Reverse geocoding (area label) --- + +it('reverse-geocodes coordinates to the admin district', function (): void { + Http::fake([ + '*/postcodes?*' => Http::response([ + 'status' => 200, + 'result' => [ + ['admin_district' => 'Peterborough', 'region' => 'East of England'], + ], + ]), + ]); + + expect($this->service->reverseResolve(52.5364, -0.2102))->toBe('Peterborough'); +}); + +it('falls back to a broader area when admin district is missing', function (): void { + Http::fake([ + '*/postcodes?*' => Http::response([ + 'status' => 200, + 'result' => [ + ['admin_district' => null, 'region' => 'Scotland'], + ], + ]), + ]); + + expect($this->service->reverseResolve(57.4, -4.2))->toBe('Scotland'); +}); + +it('returns null when reverse geocoding finds no area', function (): void { + Http::fake([ + '*/postcodes?*' => Http::response(['status' => 200, 'result' => null]), + ]); + + expect($this->service->reverseResolve(0.0, 0.0))->toBeNull(); +}); + +it('returns null when the reverse geocode request fails', function (): void { + Http::fake([ + '*/postcodes?*' => Http::response([], 500), + ]); + + expect($this->service->reverseResolve(52.5364, -0.2102))->toBeNull(); +}); + +it('caches the reverse-geocoded area per bucket', function (): void { + Http::fake([ + '*/postcodes?*' => Http::response([ + 'status' => 200, + 'result' => [['admin_district' => 'Peterborough']], + ]), + ]); + + // Two coordinates inside the same ~1km (2dp) bucket → one HTTP call. + $this->service->reverseResolve(52.5364, -0.2102); + $this->service->reverseResolve(52.5359, -0.2148); + + Http::assertSentCount(1); +}); + +it('queries postcodes.io with a wide radius so low-density buckets still match', function (): void { + Http::fake([ + '*/postcodes?*' => Http::response([ + 'status' => 200, + 'result' => [['admin_district' => 'Peterborough']], + ]), + ]); + + $this->service->reverseResolve(52.54, -0.25); + + // The default 100m radius misses the bucket centroid in rural areas; we send 2000m. + Http::assertSent(fn ($request) => str_contains($request->url(), 'radius=2000')); +});