# Multi-Company Site-Scoping — Implementation Plan (SCRUM-287)

> **PROGRESS (2026-07-15).** Architecture rebuilt correctly: the **group is the tenant boundary**
> (`TenantScope` + `Vehicle::accessibleTenant` now use `AccessibleCompanyResolver::accessibleForRequest`,
> un-narrowed), and the **company selector narrows purely by SITE** via `App\Services\Tenancy\CompanySiteScope`
> (`viaVehicle` / `viaEmployee` / `direct`). So nothing blanks on a company switch. **DONE + scoped:** sites,
> vehicles, employees, dashboard (all tabs+header), expenses, fuel/charging histories, issues, sinistres
> (list+report+stats), inhouse services, vehicle/service reminders (+exports), mobility expenses (via-employee),
> toll-badges & fuel-cards (direct site_id). **REMAINING:** vehicle-orders/maintenance-plans/campaigns need their
> own `site_id` (no vehicle/employee link); 3 report controllers (MobilityExpenseReport, VehicleRemindersReport,
> InhouseServiceSummaryReport) still unscoped; AEN/TAI/map aggregates; toll-badge/fuel-card refine to
> `site_id IN … OR vehicle.site_id IN …` (direct-only drops a NULL-site card on a company's vehicle).



Product rule (feedback 2026-07-11):
1. Every list shows only records tied to the **selected company's sites**. An entity with no direct site link is scoped through its **vehicle** (`vehicles.site_id`) or its **employee** (`user_sites` pivot).
2. An entity that relates to neither a vehicle nor an employee must carry its **own required `site_id`**.
3. `site_id` is **required** on vehicle & employee (create/update).

**Canonical pattern** (copy everywhere): `VehicleController::tenantVehicleQuery` (`app/Http/Controllers/Api/Fleet/Vehicle/VehicleController.php`). `AccessibleCompanyResolver::activeCompanySiteIds($request) === null` ⇒ whole-group (unchanged); non-null ⇒ bound to `accessibleOwnerIds` (whole group, NOT the narrowed child owner — the `ece8a9e4` bug) **+** filter by the company's site ids. Every list gates on `activeCompanySiteIds() === null` to stay inert for the whole-group view.

---

## 1. FOUNDATION — `site_id` required on vehicle & employee  ✅ DONE (validation only)

- `VehicleV2/StoreRequest` `site_id` → `required` + group-scoped `exists`.
- `VehicleV2/UpdateDetailsRequest` `site_id` → `sometimes|required` + group-scoped `exists` (partial edits keep existing site; can't clear).
- `VehicleV2/UpdateSiteRequest` `new_site_id` → group-scoped `exists` (already required).
- `Employee/StoreRequest` `sites` → `required|array|min:1`, `sites.*` group-scoped.
- `Employee/UpdateRequest` `sites` → `sometimes|required|array|min:1`, `sites.*` group-scoped.
- `EmployeeService::updateEmployee` — **blocker fix**: the site pivot is only rewritten when `sites` is present (was wiping on every partial edit).
- New trait `App\Http\Requests\Concerns\ScopesSiteToGroup::groupSiteExistsRule()` — closes the cross-tenant `exists:sites,id` leak (every site rule accepted any tenant's site id).

**No DB `NOT NULL` yet** — legacy NULL-site rows exist. Enforcement is validation-only so reads/edits of legacy rows don't break.

### Open decisions before the rest can go live
- **Backfill legacy NULL-site vehicles/employees** to a default site per owner, **and stamp that site's `company_id`** (a site with NULL `company_id` is invisible to every company-scoped list).
- **Imports** (net-new, not one-line): `config/import.php` vehicles `site_name` → required + `resolveSiteByName` must stamp `company_id`; **employee import has no site path at all** — add a `site_name` column + resolver + a post-insert `UserSite` hook; `VehicleImportController` bulk import/update has no validation and mints sites with NULL `company_id`.

---

## 2. VIA-VEHICLE surfaces — `whereHas('vehicle', fn($q) => $q->whereIn('site_id', $siteIds))`

Owner bound = whole group; gate on `activeCompanySiteIds() === null`. **Do exports in lockstep** (several bypass the list builder via `company*` home-owner relations and would leak the whole group under a company selection).

**Tier A (clean, single builder, `vehicle_id` present):** Expenses (list `ExpenseController` + export via `expense_vehicles` pivot), Fuel histories (list; export bypasses builder), Charging histories, Sinistres (list; stats/report use home-owner relation), InhouseService, Vehicle reminders (+ export), Service reminders (+ two exports), Issues (+ export), Inspections (manual `site_id_filter` join already present), Contracts (also not multi-company aware today).

**Tier B (need owner-widening to the group first — home-only today, no TenantScope):** PartnerService, VehicleAssignment (calendar + table; latent `vehicles.site_id` no-join bug), Conveying (list + show hard-gate home company).

**Cards (own nullable `site_id` + vehicle):** FuelCard, TollBadge → `site_id IN sites OR vehicle.site_id IN sites` (strict equality drops a card on a NULL-site vehicle).

---

## 3. VIA-EMPLOYEE surfaces — `whereHas('employee.employeeSites', fn($q) => $q->whereIn('site_id', $siteIds))`

MobilityExpense (list; export bypasses builder), Employee↔campaign conversations (inherits the already-narrowed employee), VehicleAssignment `grouped_by=drivers` path.

---

## 4. NEEDS-OWN-`site_id` — new required column + migration (rule 2)

- **MaintenancePlan** (template; M2M vehicles) — add own `site_id` (or aggregate-scope via assigned vehicles' sites — product call).
- **Campaign** — add own required `site_id` (or aggregate-scope via recipient `user_sites`).
- **Employee mass-import** — no site mechanism (see §1 imports).

**Aggregate-scope (multi-site spanning, don't force a single `site_id`):** TelematicsBoxOrder (via `line_vehicles.site_id`), GenericReminder & InspectionForm templates (via M2M vehicles — **caveat:** `applies_to_all_vehicles` / no-pivot templates vanish → product decision).

**Standalone company catalogs — no site scoping:** SinistreAssuranceStatus, report catalog metadata.

---

## 5. AGGREGATES (dashboards / TCO / reports)

| Surface | Do | Note |
|---|---|---|
| Live map (`VehicleLocationController`, 3 pools) | NOW | add `whereIn('site_id',$siteIds)` to all three + widen to `accessibleOwnerIds`. Miss one → GPS leaks. |
| AEN overview/export (`ResolvesAenScope::aenVehicleQuery`) | NOW | single chokepoint. |
| TAI (`TaiReportController`) | NOW | synchronous — call `activeCompanySiteIds()` directly. |
| Dashboard v2 (`DashboardController` / `OverviewTabService::fleetQuery`) | NOW, careful | feed `activeCompanySiteIds` into `$filters->siteIds`; **fix `scopedCompanyId` (narrows to single child owner = `ece8a9e4` regression)**; audit ~41 direct `where('company_id',…)` not routed through `fleetQuery`. |
| VehicleTCO / VehicleOverview | none | per-vehicle detail, already gated by the route-bound vehicle. |
| Report generators (~93) | **DEFER — separate epic** | **async job** — can't call request-based `activeCompanySiteIds()`; must resolve + persist the site-set + active company at dispatch and thread into all 93 generators. |

**Rule for aggregates:** `null` ⇒ unchanged; `[]` (company chosen, no sites) ⇒ legitimately empty — never let empty degrade to "no filter."

---

## 6. RISKS & SEQUENCING

1. Shared helper first (owner-scoped `exists` rule ✅ + a query helper wrapping the `tenantVehicleQuery` guard) — prevents 30 divergent copies.
2. Employee update wipe guard ✅ — must precede required sites.
3. Foundation writes ✅ + cross-tenant `exists` scoping ✅.
4. Via-vehicle / via-employee Tier A (list narrowing, low risk).
5. Tier B owner-widening surfaces (PartnerService, VehicleAssignment, Conveying).
6. Exports in lockstep with their lists.
7. Needs-own-`site_id` migrations (+ product decisions).
8. Aggregates now-tier (map, AEN, TAI, dashboard).
9. Report generators — last, separate epic.

**Biggest risks:** employee partial-update wipe (fixed); legacy NULL-site rows vanish under a company selection (⇒ backfill before any DB constraint); cross-tenant `exists` leak (fixed); `ece8a9e4` re-introduction anywhere that narrows to the child owner; Expense with zero vehicles (no vehicle/employee → needs own `site_id` or a whole-group fallback); template drop-out (`applies_to_all_vehicles`); importer-minted sites with NULL `company_id`.

**Pre-existing leak found in audit (fix independently):** `InspectionFormController` read methods have **no owner scoping** — any tenant reads any form.
