# Eager-Loading Optimization & Filter Service Design

**Date:** 2026-06-24  
**Scope:** Performance fixes for eager-loading / N+1 issues across the Turista API, plus extraction of `UnitSearchController` filtering logic into a reusable `FilterService`.

---

## Goals

1. Fix all eager-loading inefficiencies identified in the audit (unbounded loads, wasted loads, N+1 bugs, missing eager loads).
2. Refactor `UnitSearchController::index` filter logic into a dedicated `FilterService` named `Filter`.
3. Reuse the new service inside `UnitController::index` so both public and owner unit listings share the same filtering implementation.
4. Preserve existing API response shapes; only optimize queries and fix bugs.

---

## Assumptions

- The project is in auto-permission mode; implementation proceeds after this spec is written.
- No breaking API contract changes are desired: resource JSON output must remain the same.
- Spatie media library collection name for unit/building photos is `documents`.
- `BuildingResource` currently renders `owner` via `UserResource`; we will switch it to `OwnerResource` because the relationship is `belongsTo(Owner::class)` and controllers already eager-load `owner`.
- The new `FilterService` will live in `App\Services\Filter` and be used by `UnitSearchController` and `UnitController`.

---

## Approach

### Option A: Monolithic `FilterService`
A single class `App\Services\Filter\FilterService` with methods `applyToUnitQuery($query, array $filters)` and `applyToBuildingQuery($query, array $filters)`. Simple, direct, easy to test.

### Option B: Pipeline of small filter classes
Each filter (LocationFilter, PriceFilter, AvailabilityFilter, etc.) is a separate invokable class coordinated by a pipeline. More extensible but over-engineered for the current scope.

### Chosen approach: A
We keep it simple: one `FilterService` that receives an Eloquent builder and a validated filter array, then applies the same conditions currently in `UnitSearchController`. This keeps the refactor minimal and the controller thin.

---

## Detailed design

### 1. New service: `App\Services\Filter\FilterService`

**Responsibilities**
- Apply public/unit listing filters to a `Unit` query builder.
- Apply building listing filters to a `Building` query builder (if needed by `BuildingController`).
- Return the modified builder; never execute it.

**Key method signatures**
```php
public function applyToUnitQuery(Builder $query, array $filters, bool $public = false): Builder
public function applyToBuildingQuery(Builder $query, array $filters): Builder
```

`$public = true` adds the public-search constraints:
- `status = 'available'`
- `building.status = 'active'`
- guest capacity, guest type, rooms, check-in/check-out availability, facility filters, payment-method filters, keyword search, sorting.

When `$public = false` (owner/admin listing), only location and price filters are applied.

### 2. `UnitSearchController::index`

Replace the inline query chain with:
```php
$perPage = $v['per_page'] ?? 12;
$query = $filterService->applyToUnitQuery(Unit::query(), $v, public: true);
$units = $query
    ->with([
        'building.region.city.country' => fn ($q) => $q->select('id', 'name'),
        'facilities' => fn ($q) => $q->select('id', 'name'),
        'media' => fn ($q) => $q->take(5),
    ])
    ->paginate($perPage)
    ->appends($request->query());
```

### 3. `UnitController::index`

Replace the duplicated location/price filters with:
```php
$query = Unit::query();
if ($building) { ... }
$query = $filterService->applyToUnitQuery($query, $request->validated(), public: false);
$query->with([
    'building' => fn ($q) => $q->select('id', 'name', 'slug'),
    'building.media' => fn ($q) => $q->take(1),
    'media' => fn ($q) => $q->take(5),
]);
return UnitResource::collection($query->paginate(10));
```
Remove the `try/catch clone()` workaround; it hides errors and mutates the builder.

### 4. Building endpoints

- `BuildingController::index` — keep `filter()` private method for buildings but optimize eager loads.
- `BuildingController::show/store/update/addPhotos/disable` — replace `units.media` with a limited/selected load or remove it if not used by the resource. Limit `media` to a small number of photos.
- `BuildingResource` — change `'owner' => UserResource::make(...)` to `'owner' => OwnerResource::make(...)`. Controllers then load `owner` instead of `owner.user`.

### 5. Wasted eager loads

- `InvoiceController::index` — remove `->with('reservation.unit.building')`.
- `ReceiptController::index/store/show` — remove `with(['reservation','invoice'])` / `load([...])`.
- `ReceiptController::store` — remove `Invoice::with('reservation.unit.building')` unless fields from those relations are needed (they are not in the shown code).

### 6. N+1 / missing eager loads

- `CustomerController::index` — move `with(['user'])` from inside the `whereHas` closure to the `Customer` query.
- `PromoCodeService::findValidForUnit` — accept a `Unit` already loaded with `building.owner`, or load it inside the service. We will load `Unit::with('building.owner')` in callers (`PromoCodeController` preview path and `ReservationService`).
- `ReservationReminderScheduler::scheduleFor` — call `$reservation->load(['unit.building', 'customer'])` at the top.
- `SendDueScheduledNotifications` — add `->with(['notifiable', 'reservation'])` to the cursor query.

### 7. Unbounded single-resource loads

- `RegionController::store/show/update` — limit/select `buildings`.
- `CountryController::store/show/update` — limit/select `cities`.
- `CityController::store/show/update` — limit/select `regions`.
- `CurrencyController::index/show` — limit/select `countries`.

### 8. Heavy listing endpoints

- `UnitAvailabilityController::index` — select only needed columns; avoid loading `reservation.customer` for all rows. Load `reservation` only when `reservation_id` is not null (use a conditional eager-load closure or post-load).
- `ReservationController::index` — replace double `pluck()` `whereIn` with `whereHas('unit.building', fn ($q) => $q->where('owner_id', $owner->id))`.
- `ReceiptController::index` — same `whereHas` replacement.

### 9. Console commands

- `DeleteUnverifiedUsers` — use `chunkById(100, ...)` instead of `get()`.
- `ReleaseExpiredPendingReservations` — keep `cursor()` but note the long transaction; for now add a small chunk note and rely on the existing cursor.

### 10. Service micro-optimizations

- `DashboardMetrics` — memoize `$owner->buildings()->pluck('id')` by accepting a `Collection` parameter or caching on the service instance.
- `ReservationService::updateReservation` — load minimal columns for `newUnit` and original unit/building.

---

## Files to modify

- `app/Services/Filter/FilterService.php` *(new)*
- `app/Http/Controllers/UnitSearchController.php`
- `app/Http/Controllers/UnitController.php`
- `app/Http/Controllers/BuildingController.php`
- `app/Http/Resources/BuildingResource.php`
- `app/Http/Controllers/InvoiceController.php`
- `app/Http/Controllers/ReceiptController.php`
- `app/Http/Controllers/ReservationController.php`
- `app/Http/Controllers/Api/Customer/CustomerController.php`
- `app/Http/Controllers/UnitAvailabilityController.php`
- `app/Http/Controllers/RegionController.php`
- `app/Http/Controllers/CountryController.php`
- `app/Http/Controllers/CityController.php`
- `app/Http/Controllers/CurrencyController.php`
- `app/Services/PromoCodeService.php`
- `app/Services/ReservationReminderScheduler.php`
- `app/Services/ReservationService.php`
- `app/Services/DashboardMetrics.php`
- `app/Console/Commands/SendDueScheduledNotifications.php`
- `app/Console/Commands/DeleteUnverifiedUsers.php`

---

## Testing strategy

1. Run existing Pest/PHPUnit suite after all changes.
2. Manually verify the public unit search still returns the same JSON shape.
3. Verify owner/admin unit listing still works and returns the same shape.
4. Spot-check `BuildingResource::owner` now returns owner data correctly.
5. Run `php artisan` command registrations to ensure no syntax errors.

---

## Risks & mitigations

| Risk | Mitigation |
|------|------------|
| Changing resource shape for `owner` | `OwnerResource` already includes `user` when loaded; switching from `UserResource` to `OwnerResource` actually fixes the current null-field bug. |
| `FilterService` misses a filter | Port every `when()` clause verbatim, then add tests/spot-checks. |
| Removing `media` eager load breaks photos | `UnitResource` / `BuildingResource` call `getMedia('documents')` lazily; limiting/closing the eager load only reduces queries, photos still render. |
| `chunkById` changes delete command behavior | Same per-user cleanup logic is preserved; only iteration changes. |

---

## Success criteria

- All audit findings are addressed with minimal, focused code changes.
- `UnitSearchController` no longer contains inline filter logic; it delegates to `FilterService`.
- `UnitController::index` reuses the same `FilterService` for location/price filtering.
- Existing tests pass and response shapes remain unchanged.
