# Location Bootstrap Cache Guide

## Problem

When an owner adds a building, the form has city and region selects. The web client was populating them by paging through the existing REST endpoints sequentially:

```
GET /api/v1/countries?page=1
GET /api/v1/cities?page=1
GET /api/v1/regions?page=1
...
```

Because each endpoint is paginated (`per_page=5/10/50`) and the client fetches one page at a time, the selects take many round-trips to become usable. This is especially slow on slower networks or when there are many locations.

## Solution

Add a single lightweight, cacheable **location bootstrap endpoint** that returns every active location in one request:

```
GET /api/v1/locations/bootstrap
```

The backend filters inactive records, sorts by name, and returns only the minimal fields the UI needs. The response is aggressively cached because locations change rarely.

---

## Step-by-step implementation

### Step 1: Create the controller

Create `app/Http/Controllers/LocationBootstrapController.php`.

Responsibilities:

- Return all countries, active cities, and active regions in one JSON payload.
- Select only `id`, `name`, and the parent foreign key.
- Sort every list by `name`.
- Cache the result for 24 hours.

```php
<?php

namespace App\Http\Controllers;

use App\Models\City;
use App\Models\Country;
use App\Models\Region;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;

class LocationBootstrapController extends Controller
{
    private const CACHE_KEY = 'locations:bootstrap';

    private const CACHE_TTL_SECONDS = 86400; // 24 hours

    public function index(): JsonResponse
    {
        $data = Cache::remember(
            self::CACHE_KEY,
            self::CACHE_TTL_SECONDS,
            fn () => $this->buildPayload()
        );

        return response()->json(['data' => $data]);
    }

    private function buildPayload(): array
    {
        return [
            'countries' => Country::query()
                ->orderBy('name')
                ->get(['id', 'name'])
                ->toArray(),

            'cities' => City::query()
                ->where('is_active', true)
                ->orderBy('name')
                ->get(['id', 'name', 'country_id'])
                ->toArray(),

            'regions' => Region::query()
                ->where('is_active', true)
                ->orderBy('name')
                ->get(['id', 'name', 'city_id'])
                ->toArray(),
        ];
    }

    public static function clearCache(): void
    {
        Cache::forget(self::CACHE_KEY);
    }
}
```

Notes:

- `Country` does not have an `is_active` column in the current schema, so all countries are returned.
- `City` and `Region` both have `is_active`, so only active rows are returned.
- The query selects specific columns to keep the payload small.

---

### Step 2: Register the route

Add the route in `routes/api.php`. It should be public because it is needed by the building-creation form (which is accessed by authenticated owners/employees, but the locations themselves are not sensitive).

```php
use App\Http\Controllers\LocationBootstrapController;

Route::prefix('v1')->group(function () {
    // ... existing routes ...

    Route::get('locations/bootstrap', [LocationBootstrapController::class, 'index'])
        ->name('locations.bootstrap');
});
```

If you prefer the endpoint to require authentication, move it inside the `auth:api` group.

---

### Step 3: Add cache invalidation

Locations change rarely, but when they do the cache must be refreshed. The cleanest way is to clear the cache whenever a location model is created, updated, or deleted.

Add the following `booted` method to each model:

#### `app/Models/Country.php`

```php
protected static function booted(): void
{
    static::saved(fn () => \App\Http\Controllers\LocationBootstrapController::clearCache());
    static::deleted(fn () => \App\Http\Controllers\LocationBootstrapController::clearCache());
}
```

#### `app/Models/City.php`

```php
protected static function booted(): void
{
    static::saved(fn () => \App\Http\Controllers\LocationBootstrapController::clearCache());
    static::deleted(fn () => \App\Http\Controllers\LocationBootstrapController::clearCache());
}
```

#### `app/Models/Region.php`

```php
protected static function booted(): void
{
    static::saved(fn () => \App\Http\Controllers\LocationBootstrapController::clearCache());
    static::deleted(fn () => \App\Http\Controllers\LocationBootstrapController::clearCache());
}
```

Alternative using observers:

1. Create an observer class:

```php
<?php

namespace App\Observers;

use App\Http\Controllers\LocationBootstrapController;
use Illuminate\Database\Eloquent\Model;

class LocationCacheObserver
{
    public function saved(Model $model): void
    {
        LocationBootstrapController::clearCache();
    }

    public function deleted(Model $model): void
    {
        LocationBootstrapController::clearCache();
    }
}
```

2. Register it in `app/Providers/AppServiceProvider.php`:

```php
use App\Models\City;
use App\Models\Country;
use App\Models\Region;
use App\Observers\LocationCacheObserver;

public function boot(): void
{
    Country::observe(LocationCacheObserver::class);
    City::observe(LocationCacheObserver::class);
    Region::observe(LocationCacheObserver::class);
}
```

---

### Step 4: Configure the cache driver

The project currently uses the `database` cache driver by default (`config/cache.php`). This works fine for the bootstrap endpoint, but Redis is preferred in production for better performance and tag support.

#### Using the database driver (default)

Make sure the `cache` table exists:

```bash
php artisan cache:table
php artisan migrate
```

No code changes are needed; `Cache::remember` will use the database.

#### Using Redis in production

1. Install the Redis PHP extension (`phpredis` or `predis`).
2. Set in `.env`:

```env
CACHE_STORE=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
```

3. With Redis you can use cache tags for easier invalidation:

```php
Cache::tags(['locations'])->remember('locations:bootstrap', 86400, fn () => $this->buildPayload());

Cache::tags(['locations'])->flush();
```

If you use tags, update the `clearCache` method accordingly.

---

### Step 5: Client-side usage

The frontend should replace the sequential `fetchAllPages` calls with a single request:

```javascript
const { data } = await api.get('/api/v1/locations/bootstrap');

const countries = data.data.countries;
const cities = data.data.cities;
const regions = data.data.regions;
```

Because the backend already filters inactive records and sorts by name, the client can remove:

- Manual pagination loops.
- Client-side filtering of inactive locations.
- Client-side sorting by name.

For the city select, filter the cached `cities` array by `country_id`. For the region select, filter `regions` by `city_id`.

---

## Response example

```json
{
  "data": {
    "countries": [
      { "id": 1, "name": "Saudi Arabia" },
      { "id": 2, "name": "United Arab Emirates" }
    ],
    "cities": [
      { "id": 1, "name": "Riyadh", "country_id": 1 },
      { "id": 2, "name": "Jeddah", "country_id": 1 },
      { "id": 3, "name": "Dubai", "country_id": 2 }
    ],
    "regions": [
      { "id": 1, "name": "Al Olaya", "city_id": 1 },
      { "id": 2, "name": "Al Hamra", "city_id": 2 },
      { "id": 3, "name": "Downtown Dubai", "city_id": 3 }
    ]
  }
}
```

---

## Alternative: individual bulk endpoints

If the frontend still needs separate endpoints, add an `all` query parameter to the existing controllers:

```php
public function index(ListCitiesRequest $request): ResourceCollection|JsonResponse
{
    $query = FilterService::applyToCityQuery(City::query(), $request->validated());

    if ($request->boolean('all')) {
        return response()->json([
            'data' => $query
                ->where('is_active', true)
                ->orderBy('name')
                ->get(['id', 'name', 'country_id']),
        ]);
    }

    return CityResource::collection($query->paginate(...));
}
```

Usage:

```
GET /api/v1/countries?all=1
GET /api/v1/cities?country_id=1&all=1
GET /api/v1/regions?city_id=1&all=1
```

This still requires 3 requests, so the combined `/locations/bootstrap` is recommended.

---

## Testing

Add a feature test that verifies:

1. The endpoint returns all active locations.
2. Inactive cities/regions are excluded.
3. Records are sorted by name.
4. The response is cached.
5. Updating a location clears the cache.

Example test skeleton:

```php
<?php

use App\Models\City;
use App\Models\Country;
use App\Models\Region;
use Illuminate\Support\Facades\Cache;

it('returns all active locations in one response', function () {
    $country = Country::factory()->create(['name' => 'Z Country']);
    $city = City::factory()->create(['country_id' => $country->id, 'name' => 'A City', 'is_active' => true]);
    Region::factory()->create(['city_id' => $city->id, 'name' => 'B Region', 'is_active' => true]);
    Region::factory()->create(['city_id' => $city->id, 'name' => 'Inactive Region', 'is_active' => false]);

    $response = $this->getJson('/api/v1/locations/bootstrap');

    $response->assertOk();
    expect($response->json('data.countries'))->toHaveCount(1)
        ->and($response->json('data.cities'))->toHaveCount(1)
        ->and($response->json('data.regions'))->toHaveCount(1)
        ->and($response->json('data.regions.0.name'))->toBe('B Region');
});

it('caches the bootstrap response', function () {
    Cache::shouldReceive('remember')
        ->once()
        ->with('locations:bootstrap', 86400, \Closure::class)
        ->andReturn(['countries' => [], 'cities' => [], 'regions' => []]);

    $this->getJson('/api/v1/locations/bootstrap')->assertOk();
});

it('clears the cache when a location is updated', function () {
    $country = Country::factory()->create();

    $this->getJson('/api/v1/locations/bootstrap')->assertOk();
    expect(Cache::has('locations:bootstrap'))->toBeTrue();

    $country->update(['name' => 'Updated Name']);

    expect(Cache::has('locations:bootstrap'))->toBeFalse();
});
```

---

## Production checklist

- [ ] `LocationBootstrapController` created and returns only minimal columns.
- [ ] Route `GET /api/v1/locations/bootstrap` registered.
- [ ] Cache invalidation wired to `saved`/`deleted` events on `Country`, `City`, `Region`.
- [ ] Cache driver configured (`database` works; Redis preferred for scale).
- [ ] Frontend updated to call the bootstrap endpoint instead of paging.
- [ ] Feature tests added for response shape, filtering, sorting, and cache invalidation.
- [ ] HTTP cache headers considered (optional): `Cache-Control: public, max-age=86400`.

---

## Notes

- The cache TTL is 24 hours by default. You can increase it to one week because the cache is invalidated automatically on changes.
- If locations are edited directly in the database, run `php artisan cache:clear` or call `LocationBootstrapController::clearCache()`.
- The endpoint intentionally returns a flat list rather than a nested tree. Nesting can be done client-side from the parent IDs if needed.
