[8.x] refactor(FlightService, PirepService, UserService): replace UserService usage with direct user methods for subfleet and aircraft access (#2217)
<!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Refactor** * Consolidated aircraft/subfleet access into model-driven authorization and moved fare override resolution to API responses for more consistent, permission-aware results. * **Tests** * Added feature and unit tests validating subfleet/aircraft access rules and enforcing query-count bounds on hot paths. * **Chores** * Added database indexes to improve query performance for aircraft/subfleet and type-rating lookups. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/phpvms/phpvms/pull/2217?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
commit
f5dc0e4c5f
@ -16,7 +16,6 @@ use App\Models\User;
|
||||
use App\Queries\FlightSearchQuery;
|
||||
use App\Services\FareService;
|
||||
use App\Services\FlightService;
|
||||
use App\Services\UserService;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Routing\ResponseFactory;
|
||||
use Illuminate\Http\Request;
|
||||
@ -31,7 +30,6 @@ class FlightController extends Controller
|
||||
private readonly FareService $fareSvc,
|
||||
private readonly FlightSearchQuery $flightSearchQuery,
|
||||
private readonly FlightService $flightSvc,
|
||||
private readonly UserService $userSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -48,15 +46,20 @@ class FlightController extends Controller
|
||||
$user = Auth::user();
|
||||
|
||||
/** @var Flight $flight */
|
||||
$flight = Flight::with([
|
||||
'airline',
|
||||
'fares',
|
||||
'subfleets' => ['aircraft.bid', 'fares'],
|
||||
'field_values',
|
||||
'simbrief' => fn ($query) => $query->with('aircraft')->where('user_id', $user->id),
|
||||
])->findOrFail($id);
|
||||
$flight = Flight::query()
|
||||
->with([
|
||||
'airline',
|
||||
'fares',
|
||||
'field_values',
|
||||
'simbrief' => fn ($query) => $query->with('aircraft')->where('user_id', $user->id),
|
||||
])
|
||||
->findOrFail($id);
|
||||
|
||||
$flight->setRelation(
|
||||
'subfleets',
|
||||
$flight->accessibleSubfleetsFor($user, ['aircraft', 'fares']),
|
||||
);
|
||||
|
||||
$flight = $this->flightSvc->filterSubfleets($user, $flight);
|
||||
$flight = $this->fareSvc->getReconciledFaresForFlight($flight);
|
||||
|
||||
return new FlightResource($flight);
|
||||
@ -100,26 +103,16 @@ class FlightController extends Controller
|
||||
$relations = explode(',', (string) $request->input('with', ''));
|
||||
}
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$with = array_merge($with, match ($relation) {
|
||||
'subfleets' => [
|
||||
'subfleets',
|
||||
'subfleets.aircraft',
|
||||
'subfleets.aircraft.bid',
|
||||
'subfleets.fares',
|
||||
],
|
||||
default => [],
|
||||
});
|
||||
$query->with($with);
|
||||
|
||||
if (in_array('subfleets', $relations, true)) {
|
||||
$query->withAccessibleSubfleets($user);
|
||||
}
|
||||
|
||||
$perPage = paginate_limit($request->integer('limit') ?: null);
|
||||
$flights = $query->with($with)->paginate($perPage);
|
||||
$flights = $query->paginate($perPage);
|
||||
|
||||
foreach ($flights as $flight) {
|
||||
if (in_array('subfleets', $relations)) {
|
||||
$this->flightSvc->filterSubfleets($user, $flight);
|
||||
}
|
||||
|
||||
$this->fareSvc->getReconciledFaresForFlight($flight);
|
||||
}
|
||||
|
||||
@ -168,33 +161,29 @@ class FlightController extends Controller
|
||||
public function aircraft(string $id, Request $request)
|
||||
{
|
||||
/** @var Flight $flight */
|
||||
$flight = Flight::with('subfleets')->findOrFail($id);
|
||||
$flight = Flight::findOrFail($id);
|
||||
|
||||
$user_subfleets = $this->userSvc->getAllowableSubfleets(Auth::user())->pluck('id')->toArray();
|
||||
$flight_subfleets = $flight->subfleets->pluck('id')->toArray();
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
$subfleet_ids = filled($flight_subfleets) ? array_intersect($user_subfleets, $flight_subfleets) : $user_subfleets;
|
||||
|
||||
// Prepare variables for single aircraft query
|
||||
$where = [];
|
||||
$where['state'] = AircraftState::PARKED;
|
||||
$where['status'] = AircraftStatus::ACTIVE;
|
||||
|
||||
if (setting('pireps.only_aircraft_at_dpt_airport')) {
|
||||
$where['airport_id'] = $flight->dpt_airport_id;
|
||||
}
|
||||
|
||||
$withCount = ['bid', 'simbriefs' => function ($query): void {
|
||||
$query->whereNull('pirep_id');
|
||||
}];
|
||||
|
||||
// Build proper aircraft collection considering all possible settings
|
||||
// Flight subfleets, user subfleet restrictions, pirep restrictions, simbrief blocking etc
|
||||
$aircraft = Aircraft::withCount($withCount)->where($where)
|
||||
->when(setting('simbrief.block_aircraft'), fn ($query) => $query->having('simbriefs_count', 0))->when(setting('bids.block_aircraft'), fn ($query) => $query->having('bid_count', 0))->whereIn('subfleet_id', $subfleet_ids)
|
||||
->orderby('icao')->orderby('registration')
|
||||
return Aircraft::query()
|
||||
->allowedFor($user, $flight)
|
||||
->where('state', AircraftState::PARKED)
|
||||
->where('status', AircraftStatus::ACTIVE)
|
||||
->when(
|
||||
$flight->subfleets()->exists(),
|
||||
fn ($q) => $q->whereIn('subfleet_id', $flight->subfleets()->pluck('subfleets.id')),
|
||||
)
|
||||
->withCount([
|
||||
'bid',
|
||||
'simbriefs' => fn ($q) => $q->whereNull('pirep_id'),
|
||||
])
|
||||
->when(
|
||||
setting('simbrief.block_aircraft'),
|
||||
fn ($q) => $q->having('simbriefs_count', 0),
|
||||
)
|
||||
->orderBy('icao')
|
||||
->orderBy('registration')
|
||||
->get();
|
||||
|
||||
return $aircraft;
|
||||
}
|
||||
}
|
||||
|
||||
@ -32,7 +32,6 @@ use App\Models\User;
|
||||
use App\Queries\JournalTransactionQuery;
|
||||
use App\Services\Finance\PirepFinanceService;
|
||||
use App\Services\PirepService;
|
||||
use App\Services\UserService;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
@ -51,7 +50,6 @@ class PirepController extends Controller
|
||||
private readonly PirepFinanceService $financeSvc,
|
||||
private readonly JournalTransactionQuery $journalTransactions,
|
||||
private readonly PirepService $pirepSvc,
|
||||
private readonly UserService $userSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -229,7 +227,7 @@ class PirepController extends Controller
|
||||
if (array_key_exists('aircraft_id', $attrs)
|
||||
&& setting('pireps.restrict_aircraft_to_rank', false)
|
||||
) {
|
||||
$can_use_ac = $this->userSvc->aircraftAllowed($user, $pirep->aircraft_id);
|
||||
$can_use_ac = $user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists();
|
||||
if (!$can_use_ac) {
|
||||
throw new AircraftPermissionDenied($user, $pirep->aircraft);
|
||||
}
|
||||
@ -271,7 +269,7 @@ class PirepController extends Controller
|
||||
if (array_key_exists('aircraft_id', $attrs)
|
||||
&& setting('pireps.restrict_aircraft_to_rank', false)
|
||||
) {
|
||||
$can_use_ac = $this->userSvc->aircraftAllowed($user, $pirep->aircraft_id);
|
||||
$can_use_ac = $user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists();
|
||||
if (!$can_use_ac) {
|
||||
throw new AircraftPermissionDenied($user, $pirep->aircraft);
|
||||
}
|
||||
|
||||
@ -159,7 +159,9 @@ class UserController extends Controller
|
||||
|
||||
$perPage = paginate_limit($request->integer('limit') ?: null);
|
||||
|
||||
$subfleets = $this->userSvc->getAllowableSubfleets($user, true, $perPage)
|
||||
$subfleets = $user->allowedSubfleets()
|
||||
->with(['aircraft', 'aircraft.bid', 'fares'])
|
||||
->paginate($perPage)
|
||||
->appends($request->except(['page', 'user']));
|
||||
|
||||
return SubfleetResource::collection($subfleets);
|
||||
|
||||
@ -15,7 +15,6 @@ use App\Queries\FlightSearchQuery;
|
||||
use App\Services\FlightService;
|
||||
use App\Services\GeoService;
|
||||
use App\Services\ModuleService;
|
||||
use App\Services\UserService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
@ -30,7 +29,6 @@ class FlightController extends Controller
|
||||
private readonly FlightService $flightSvc,
|
||||
private readonly GeoService $geoSvc,
|
||||
private readonly ModuleService $moduleSvc,
|
||||
private readonly UserService $userSvc
|
||||
) {}
|
||||
|
||||
public function index(SearchFlightsRequest $request): View
|
||||
@ -66,7 +64,7 @@ class FlightController extends Controller
|
||||
|
||||
if ($filter_by_user) {
|
||||
// Get allowed subfleets for the user
|
||||
$user_subfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id')->toArray();
|
||||
$user_subfleets = $user->allowedSubfleets()->pluck('id')->all();
|
||||
$allowed_flights = $this->flightSvc->getAccessibleFlightIds($user);
|
||||
// Build aircraft icao codes by considering allowed subfleets
|
||||
$icao_codes = Aircraft::whereIn('subfleet_id', $user_subfleets)->groupBy('icao')->orderBy('icao')->pluck('icao')->toArray();
|
||||
@ -214,9 +212,10 @@ class FlightController extends Controller
|
||||
return redirect(route('frontend.dashboard.index'));
|
||||
}
|
||||
|
||||
if (setting('flights.only_company_aircraft', false)) {
|
||||
$flight = $this->flightSvc->filterSubfleets($user, $flight);
|
||||
}
|
||||
$flight->setRelation(
|
||||
'subfleets',
|
||||
$flight->accessibleSubfleetsFor($user, ['airline']),
|
||||
);
|
||||
|
||||
$map_features = $this->geoSvc->flightGeoJson($flight);
|
||||
|
||||
|
||||
@ -18,17 +18,18 @@ use App\Models\Pirep;
|
||||
use App\Models\PirepFare;
|
||||
use App\Models\PirepField;
|
||||
use App\Models\SimBrief;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
use App\Queries\PirepSearchQuery;
|
||||
use App\Services\FareService;
|
||||
use App\Services\GeoService;
|
||||
use App\Services\PirepService;
|
||||
use App\Services\SimBriefService;
|
||||
use App\Services\UserService;
|
||||
use App\Support\Units\Fuel;
|
||||
use App\Support\Units\Time;
|
||||
use Carbon\Carbon;
|
||||
use Exception;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
@ -44,7 +45,6 @@ class PirepController extends Controller
|
||||
private readonly GeoService $geoSvc,
|
||||
private readonly PirepSearchQuery $pirepSearchQuery,
|
||||
private readonly PirepService $pirepSvc,
|
||||
private readonly UserService $userSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -59,18 +59,25 @@ class PirepController extends Controller
|
||||
$location_check = setting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
|
||||
$aircraft = [];
|
||||
$subfleets = $this->userSvc->getAllowableSubfleets($user);
|
||||
|
||||
if ($add_blank) {
|
||||
$aircraft[''] = '';
|
||||
}
|
||||
|
||||
$subfleets->loadMissing('aircraft');
|
||||
/** @var Collection<int, Subfleet> $subfleets */
|
||||
$subfleets = $user->allowedSubfleets()
|
||||
->with([
|
||||
'aircraft' => fn ($q) => $q->when(
|
||||
$location_check,
|
||||
fn ($q2) => $q2->where('airport_id', $user_loc),
|
||||
),
|
||||
])
|
||||
->get();
|
||||
|
||||
foreach ($subfleets as $subfleet) {
|
||||
$tmp = [];
|
||||
foreach ($subfleet->aircraft->when($location_check, fn ($query) => $query->where('airport_id', $user_loc)) as $ac) {
|
||||
$tmp[$ac->id] = $ac['name'].' - '.$ac['registration'];
|
||||
foreach ($subfleet->aircraft as $ac) {
|
||||
$tmp[$ac->id] = $ac->name.' - '.$ac->registration;
|
||||
}
|
||||
|
||||
$aircraft[$subfleet->type] = $tmp;
|
||||
@ -322,7 +329,7 @@ class PirepController extends Controller
|
||||
|
||||
// Can they fly this aircraft?
|
||||
if (setting('pireps.restrict_aircraft_to_rank', false)
|
||||
&& !$this->userSvc->aircraftAllowed($user, $pirep->aircraft_id)) {
|
||||
&& !$user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists()) {
|
||||
Log::info('Pilot '.$user->id.' not allowed to fly aircraft');
|
||||
|
||||
return $this->flashError(
|
||||
|
||||
@ -19,7 +19,6 @@ use App\Models\User;
|
||||
use App\Services\FareService;
|
||||
use App\Services\ModuleService;
|
||||
use App\Services\SimBriefService;
|
||||
use App\Services\UserService;
|
||||
use Exception;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
@ -33,7 +32,6 @@ class SimBriefController
|
||||
private readonly FareService $fareSvc,
|
||||
private readonly ModuleService $moduleSvc,
|
||||
private readonly SimBriefService $simBriefSvc,
|
||||
private readonly UserService $userSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -85,35 +83,23 @@ class SimBriefController
|
||||
|
||||
// No aircraft selected, show selection form
|
||||
if (!$aircraft_id) {
|
||||
// Get user's allowed subfleets and intersect it with flight subfleets
|
||||
// so we will have a proper list which the user is allowed to fly
|
||||
$user_subfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id')->toArray();
|
||||
$flight_subfleets = $flight->subfleets->pluck('id')->toArray();
|
||||
$subfleet_ids = $flight->accessibleSubfleetsFor($user)->pluck('id');
|
||||
|
||||
if ((blank($flight_subfleets) || count($flight_subfleets) === 0) && setting('flights.only_company_aircraft', false)) {
|
||||
$flight_subfleets = Subfleet::where(['airline_id' => $flight->airline_id])->pluck('id')->toArray();
|
||||
}
|
||||
|
||||
$subfleet_ids = filled($flight_subfleets) ? array_intersect($user_subfleets, $flight_subfleets) : $user_subfleets;
|
||||
|
||||
// Prepare variables for single aircraft query
|
||||
$where = [];
|
||||
$where['state'] = AircraftState::PARKED;
|
||||
$where['status'] = AircraftStatus::ACTIVE;
|
||||
|
||||
if (setting('pireps.only_aircraft_at_dpt_airport')) {
|
||||
$where['airport_id'] = $flight->dpt_airport_id;
|
||||
}
|
||||
|
||||
$withCount = ['simbriefs' => function ($query): void {
|
||||
$query->whereNull('pirep_id');
|
||||
}];
|
||||
|
||||
// Build proper aircraft collection considering all possible settings
|
||||
// Flight subfleets, user subfleet restrictions, pirep restrictions, simbrief blocking etc
|
||||
$aircraft = Aircraft::withCount($withCount)->with(['sbaircraft', 'sbairframes'])->where($where)
|
||||
->when(setting('simbrief.block_aircraft'), fn ($query) => $query->having('simbriefs_count', 0))->whereIn('subfleet_id', $subfleet_ids)
|
||||
->orderby('icao')->orderby('registration')
|
||||
$aircraft = Aircraft::query()
|
||||
->allowedFor($user, $flight)
|
||||
->where('state', AircraftState::PARKED)
|
||||
->where('status', AircraftStatus::ACTIVE)
|
||||
->whereIn('subfleet_id', $subfleet_ids)
|
||||
->with(['sbaircraft', 'sbairframes'])
|
||||
->withCount([
|
||||
'simbriefs' => fn ($q) => $q->whereNull('pirep_id'),
|
||||
])
|
||||
->when(
|
||||
setting('simbrief.block_aircraft'),
|
||||
fn ($q) => $q->having('simbriefs_count', 0),
|
||||
)
|
||||
->orderBy('icao')
|
||||
->orderBy('registration')
|
||||
->get();
|
||||
|
||||
return view('flights.simbrief_aircraft', [
|
||||
|
||||
@ -6,6 +6,7 @@ namespace App\Http\Resources;
|
||||
|
||||
use App\Contracts\Resource;
|
||||
use App\Models\Subfleet;
|
||||
use App\Services\FareService;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
@ -17,7 +18,14 @@ class SubfleetResource extends Resource
|
||||
public function toArray(Request $request)
|
||||
{
|
||||
$res = parent::toArray($request);
|
||||
$res['fares'] = FareResource::collection($this->fares);
|
||||
|
||||
// Apply subfleet-level fare overrides at the response boundary.
|
||||
// The access-control loader no longer bakes these in (the financial
|
||||
// path through FareService::saveToPirep recomputes server-side at
|
||||
// file time and is independent of the loader).
|
||||
$fares = app(FareService::class)->getForSubfleet($this->resource);
|
||||
$res['fares'] = FareResource::collection($fares);
|
||||
|
||||
$res['aircraft'] = AircraftResource::collection($this->aircraft);
|
||||
|
||||
return $res;
|
||||
|
||||
@ -8,10 +8,13 @@ use App\Contracts\Model;
|
||||
use App\Enums\AircraftState;
|
||||
use App\Enums\AircraftStatus;
|
||||
use App\Observers\AircraftObserver;
|
||||
use App\Support\SubfleetAccessPolicy;
|
||||
use App\Traits\ExpensableTrait;
|
||||
use App\Traits\FilesTrait;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
@ -272,6 +275,17 @@ class Aircraft extends Model
|
||||
return $this->belongsTo(Subfleet::class, 'subfleet_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to aircraft the given user is allowed to operate. Optionally
|
||||
* scope to a flight context to apply departure-airport restriction.
|
||||
* See SubfleetAccessPolicy.
|
||||
*/
|
||||
#[Scope]
|
||||
protected function allowedFor(Builder $query, User $user, ?Flight $flight = null): Builder
|
||||
{
|
||||
return (new SubfleetAccessPolicy($user, $flight))->applyToAircraft($query);
|
||||
}
|
||||
|
||||
public function sbaircraft(): HasOne
|
||||
{
|
||||
return $this->hasOne(SimBriefAircraft::class, 'icao', 'icao');
|
||||
|
||||
@ -373,6 +373,37 @@ class Flight extends Model
|
||||
return $this->belongsToMany(Subfleet::class, 'flight_subfleet');
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the subfleets shown for this flight given a user.
|
||||
*
|
||||
* Pinned subfleets win. If none are pinned, fall back to:
|
||||
* - airline subfleets when `flights.only_company_aircraft` is on
|
||||
* - all user-allowed subfleets otherwise
|
||||
*
|
||||
* User access constraints (rank / type rating) are applied throughout.
|
||||
* Use this in single-flight controllers; list endpoints use the
|
||||
* `withAccessibleSubfleets` scope which skips the fallback.
|
||||
*/
|
||||
public function accessibleSubfleetsFor(User $user, array $with = []): Collection
|
||||
{
|
||||
$pinned = Subfleet::query()
|
||||
->allowedFor($user)
|
||||
->whereHas('flights', fn ($q) => $q->whereKey($this->id))
|
||||
->with($with)
|
||||
->get();
|
||||
|
||||
if ($pinned->isNotEmpty()) {
|
||||
return $pinned;
|
||||
}
|
||||
|
||||
$fallback = Subfleet::query()->allowedFor($user)->with($with);
|
||||
if (setting('flights.only_company_aircraft', false)) {
|
||||
$fallback->where('airline_id', $this->airline_id);
|
||||
}
|
||||
|
||||
return $fallback->get();
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
@ -542,4 +573,28 @@ class Flight extends Model
|
||||
fn (Builder $sq) => $sq->whereIn('subfleets.id', $subfleetIds)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eager-load subfleets, their aircraft, and their fares constrained to the
|
||||
* user's access policy. No flight context is passed to the aircraft scope
|
||||
* here — airport restriction is a per-flight concern handled by
|
||||
* single-flight callers via `Aircraft::allowedFor($user, $flight)`. Rank,
|
||||
* type-rating, and bid-block constraints still apply.
|
||||
*
|
||||
* `fares` is eager-loaded because callers commonly run the result through
|
||||
* `FareService::getReconciledFaresForFlight()`, which reads
|
||||
* `$subfleet->fares`. Without that load, lazy-loading kicks in and trips
|
||||
* `preventLazyLoading()` in non-prod environments.
|
||||
*/
|
||||
#[Scope]
|
||||
protected function withAccessibleSubfleets(Builder $query, User $user): Builder
|
||||
{
|
||||
return $query->with([
|
||||
'subfleets' => fn ($sq) => $sq->allowedFor($user)->with([
|
||||
'aircraft' => fn ($aq) => $aq->allowedFor($user),
|
||||
'aircraft.bid',
|
||||
'fares',
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,9 +7,12 @@ use App\Enums\AircraftStatus;
|
||||
use App\Enums\FlightType;
|
||||
use App\Enums\FuelType;
|
||||
use App\Observers\SubfleetObserver;
|
||||
use App\Support\SubfleetAccessPolicy;
|
||||
use App\Traits\ExpensableTrait;
|
||||
use App\Traits\FilesTrait;
|
||||
use Illuminate\Database\Eloquent\Attributes\ObservedBy;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\AsEnumCollection;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@ -203,6 +206,16 @@ class Subfleet extends Model
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict to subfleets the given user is allowed to operate, based on
|
||||
* rank and type-rating settings. See SubfleetAccessPolicy.
|
||||
*/
|
||||
#[Scope]
|
||||
protected function allowedFor(Builder $query, User $user): Builder
|
||||
{
|
||||
return (new SubfleetAccessPolicy($user))->applyToSubfleets($query);
|
||||
}
|
||||
|
||||
/**
|
||||
* The attributes that should be cast to native types.
|
||||
*/
|
||||
|
||||
@ -446,6 +446,26 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, MustVerif
|
||||
return $this->hasManyDeep(Subfleet::class, ['typerating_user', Typerating::class, 'typerating_subfleet']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable query for the subfleets this user is allowed to operate, given
|
||||
* the current restrict_aircraft_to_rank / restrict_aircraft_to_typerating
|
||||
* settings. Callers chain ->get(), ->paginate($per), ->pluck('id'), etc.
|
||||
*/
|
||||
public function allowedSubfleets(): Builder
|
||||
{
|
||||
return Subfleet::query()->allowedFor($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Composable query for the aircraft this user is allowed to operate.
|
||||
* Pass a Flight to apply the only_aircraft_at_dpt_airport setting against
|
||||
* that flight's departure airport.
|
||||
*/
|
||||
public function allowedAircraft(?Flight $flight = null): Builder
|
||||
{
|
||||
return Aircraft::query()->allowedFor($this, $flight);
|
||||
}
|
||||
|
||||
public function canAccessPanel(Panel $panel): bool
|
||||
{
|
||||
// For phpvms panels
|
||||
|
||||
@ -22,7 +22,6 @@ class BidService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FareService $fareSvc,
|
||||
private readonly FlightService $flightSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -40,10 +39,6 @@ class BidService extends Service
|
||||
},
|
||||
'flight.simbrief.aircraft',
|
||||
'flight.simbrief.aircraft.subfleet',
|
||||
'flight.subfleets',
|
||||
'flight.subfleets.aircraft',
|
||||
'flight.subfleets.aircraft.bid',
|
||||
'flight.subfleets.fares',
|
||||
];
|
||||
|
||||
/** @var ?Bid $bid */
|
||||
@ -52,17 +47,28 @@ class BidService extends Service
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reconcile the aircraft for this bid
|
||||
// TODO: Only do this if there isn't a Simbrief attached?
|
||||
if (!empty($bid->aircraft)) {
|
||||
$bid->flight->subfleets = $this->flightSvc->getSubfleetsForBid($bid);
|
||||
} else {
|
||||
// @phpstan-ignore-next-line
|
||||
$bid->flight = $this->flightSvc->filterSubfleets($user, $bid->flight);
|
||||
}
|
||||
if ($bid->flight !== null) {
|
||||
if ($bid->aircraft !== null) {
|
||||
// Bid is for a specific aircraft — show only that aircraft's subfleet
|
||||
$bid->flight->setRelation(
|
||||
'subfleets',
|
||||
$bid->flight->subfleets()
|
||||
->where('subfleets.id', $bid->aircraft->subfleet_id)
|
||||
->with([
|
||||
'fares',
|
||||
'aircraft' => fn ($q) => $q->where('id', $bid->aircraft_id),
|
||||
])
|
||||
->get(),
|
||||
);
|
||||
} else {
|
||||
$bid->flight->setRelation(
|
||||
'subfleets',
|
||||
$bid->flight->accessibleSubfleetsFor($user, ['aircraft.bid', 'fares']),
|
||||
);
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$bid->flight = $this->fareSvc->getReconciledFaresForFlight($bid->flight);
|
||||
$this->fareSvc->getReconciledFaresForFlight($bid->flight);
|
||||
}
|
||||
|
||||
return $bid;
|
||||
}
|
||||
@ -86,36 +92,41 @@ class BidService extends Service
|
||||
},
|
||||
];
|
||||
|
||||
foreach ($relations as $relation) {
|
||||
$with = array_merge($with, match ($relation) {
|
||||
'subfleets' => [
|
||||
'flight.subfleets',
|
||||
'flight.subfleets.aircraft',
|
||||
'flight.subfleets.aircraft.bid',
|
||||
'flight.subfleets.fares',
|
||||
],
|
||||
'simbrief_aircraft' => [
|
||||
'flight.simbrief.aircraft',
|
||||
'flight.simbrief.aircraft.subfleet',
|
||||
'flight.simbrief.aircraft.subfleet.fares',
|
||||
],
|
||||
default => [],
|
||||
});
|
||||
$loadSubfleets = in_array('subfleets', $relations, true);
|
||||
|
||||
if ($loadSubfleets) {
|
||||
// Eager-load filtered subfleets + their fares + aircraft via the
|
||||
// access-policy scope in a single query plan per relation.
|
||||
$with['flight'] = fn ($q) => $q->withAccessibleSubfleets($user);
|
||||
}
|
||||
|
||||
if (in_array('simbrief_aircraft', $relations, true)) {
|
||||
$with = array_merge($with, [
|
||||
'flight.simbrief.aircraft',
|
||||
'flight.simbrief.aircraft.subfleet',
|
||||
'flight.simbrief.aircraft.subfleet.fares',
|
||||
]);
|
||||
}
|
||||
|
||||
$bids = Bid::with($with)->where(['user_id' => $user->id])->get();
|
||||
|
||||
if (in_array('subfleets', $relations, true)) {
|
||||
if ($loadSubfleets) {
|
||||
foreach ($bids as $bid) {
|
||||
if ($bid->aircraft) {
|
||||
$bid->flight->subfleets = $this->flightSvc->getSubfleetsForBid($bid);
|
||||
} else {
|
||||
// @phpstan-ignore-next-line
|
||||
$bid->flight = $this->flightSvc->filterSubfleets($user, $bid->flight);
|
||||
if ($bid->flight === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// @phpstan-ignore-next-line
|
||||
$bid->flight = $this->fareSvc->getReconciledFaresForFlight($bid->flight);
|
||||
// If the bid is for a specific aircraft, narrow the subfleet list
|
||||
// to that aircraft's subfleet only — preserves the historic UX of
|
||||
// showing only the booked aircraft on the bid card.
|
||||
if ($bid->aircraft !== null) {
|
||||
$bid->flight->setRelation(
|
||||
'subfleets',
|
||||
$bid->flight->subfleets->where('id', $bid->aircraft->subfleet_id)->values(),
|
||||
);
|
||||
}
|
||||
|
||||
$this->fareSvc->getReconciledFaresForFlight($bid->flight);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,13 +8,11 @@ use App\Contracts\Service;
|
||||
use App\Enums\PirepState;
|
||||
use App\Enums\PirepStatus;
|
||||
use App\Exceptions\DuplicateFlight;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Flight;
|
||||
use App\Models\FlightFieldValue;
|
||||
use App\Models\Navdata;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
use App\Support\Days;
|
||||
use App\Support\Units\Time;
|
||||
@ -25,7 +23,6 @@ class FlightService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AirportService $airportSvc,
|
||||
private readonly UserService $userSvc
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -122,7 +119,7 @@ class FlightService extends Service
|
||||
*/
|
||||
public function getAccessibleFlightIds(User $user): array
|
||||
{
|
||||
$userSubfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id')->all();
|
||||
$userSubfleets = $user->allowedSubfleets()->pluck('id')->all();
|
||||
|
||||
$userFlights = Flight::query()
|
||||
->whereHas('subfleets', static function ($query) use ($userSubfleets): void {
|
||||
@ -142,88 +139,6 @@ class FlightService extends Service
|
||||
return array_values(array_unique(array_merge($userFlights, $openFlights)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the proper subfleets for the given bid
|
||||
*
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getSubfleetsForBid(Bid $bid)
|
||||
{
|
||||
return Subfleet::with([
|
||||
'fares',
|
||||
'aircraft' => function ($query) use ($bid): void {
|
||||
$query->where('id', $bid->aircraft_id);
|
||||
}])
|
||||
->where('id', $bid->aircraft->subfleet_id)
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter out subfleets to only include aircraft that a user has access to
|
||||
*/
|
||||
public function filterSubfleets(User $user, Flight $flight): Flight
|
||||
{
|
||||
// Eager load some of the relationships needed
|
||||
// $flight->load(['flight.subfleets', 'flight.subfleets.aircraft', 'flight.subfleets.fares']);
|
||||
$subfleets = $flight->subfleets;
|
||||
|
||||
// If no subfleets assigned and airline subfleets are forced, get airline subfleets
|
||||
if ($subfleets->count() === 0 && setting('flights.only_company_aircraft', false)) {
|
||||
$subfleets = Subfleet::where(['airline_id' => $flight->airline_id])->get();
|
||||
}
|
||||
|
||||
// If no subfleets assigned to a flight get users allowed subfleets
|
||||
if ($subfleets->count() === 0) {
|
||||
$subfleets = $this->userSvc->getAllowableSubfleets($user);
|
||||
}
|
||||
|
||||
// If subfleets are still empty return the flight
|
||||
if ($subfleets->count() === 0) {
|
||||
return $flight;
|
||||
}
|
||||
|
||||
// Only allow aircraft that the user has access to by their rank or type rating
|
||||
if (setting('pireps.restrict_aircraft_to_rank', false) || setting('pireps.restrict_aircraft_to_typerating', false)) {
|
||||
$allowed_subfleets = $this->userSvc->getAllowableSubfleets($user)->pluck('id');
|
||||
$subfleets = $subfleets->filter(fn (Subfleet $subfleet, $i) => $allowed_subfleets->contains($subfleet->id));
|
||||
}
|
||||
|
||||
/*
|
||||
* Only allow aircraft that are at the current departure airport
|
||||
*/
|
||||
$aircraft_at_dpt_airport = setting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
$aircraft_not_booked = setting('bids.block_aircraft', false);
|
||||
|
||||
if ($aircraft_at_dpt_airport || $aircraft_not_booked) {
|
||||
// @phpstan-ignore-next-line
|
||||
$subfleets->loadMissing('aircraft');
|
||||
|
||||
foreach ($subfleets as $subfleet) {
|
||||
/** @var Subfleet $subfleet */
|
||||
// @phpstan-ignore-next-line
|
||||
$subfleet->aircraft = $subfleet->aircraft->filter(
|
||||
function ($aircraft, $i) use ($user, $flight, $aircraft_at_dpt_airport, $aircraft_not_booked): bool {
|
||||
if ($aircraft_at_dpt_airport && $aircraft->airport_id !== $flight->dpt_airport_id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($aircraft_not_booked && $aircraft->bid && $aircraft->bid->user_id !== $user->id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
)->sortBy(fn (Aircraft $ac, int $_): bool => !empty($ac->bid));
|
||||
}
|
||||
}
|
||||
|
||||
/** @phpstan-ignore-next-line */
|
||||
$flight->subfleets = $subfleets;
|
||||
|
||||
return $flight;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this flight has a duplicate already.
|
||||
*
|
||||
|
||||
@ -112,7 +112,7 @@ class PirepService extends Service
|
||||
|
||||
// See if this user is allowed to fly this aircraft
|
||||
if (setting('pireps.restrict_aircraft_to_rank', false)
|
||||
&& !$this->userSvc->aircraftAllowed($user, $pirep->aircraft_id)) {
|
||||
&& !$user->allowedAircraft()->whereKey($pirep->aircraft_id)->exists()) {
|
||||
throw new AircraftPermissionDenied($user, $pirep->aircraft);
|
||||
}
|
||||
|
||||
|
||||
@ -11,13 +11,11 @@ use App\Events\UserStateChanged;
|
||||
use App\Events\UserStatsChanged;
|
||||
use App\Exceptions\PilotIdNotFound;
|
||||
use App\Exceptions\UserPilotIdExists;
|
||||
use App\Models\Aircraft;
|
||||
use App\Models\Airline;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Pirep;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Role;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\Typerating;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
@ -30,16 +28,11 @@ use GuzzleHttp\Client;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use Illuminate\Auth\Events\Registered;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Pagination\LengthAwarePaginator;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class UserService extends Service
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FareService $fareSvc,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Find the user and return them with all of the data properly attached
|
||||
*/
|
||||
@ -62,11 +55,11 @@ class UserService extends Service
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($with_subfleets) {
|
||||
// Load the proper subfleets to the rank
|
||||
$user->rank->subfleets = $this->getAllowableSubfleets($user);
|
||||
// @phpstan-ignore-next-line
|
||||
$user->subfleets = $user->rank->subfleets;
|
||||
if ($with_subfleets && $user->rank !== null) {
|
||||
$user->rank->setRelation(
|
||||
'subfleets',
|
||||
$user->allowedSubfleets()->with(['aircraft', 'fares'])->get(),
|
||||
);
|
||||
}
|
||||
|
||||
return $user;
|
||||
@ -332,63 +325,6 @@ class UserService extends Service
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the subfleets this user is allowed access to,
|
||||
* based on their current Rank and/or by Type Rating
|
||||
*
|
||||
* @param int|null $perPage Page size when paginating; null uses Laravel's default
|
||||
* @return LengthAwarePaginator<int, Subfleet>|Collection<int, Subfleet>
|
||||
*/
|
||||
public function getAllowableSubfleets($user, bool $paginate = false, ?int $perPage = null)
|
||||
{
|
||||
$restrict_rank = setting('pireps.restrict_aircraft_to_rank', true);
|
||||
$restrict_type = setting('pireps.restrict_aircraft_to_typerating', false);
|
||||
$restricted_to = [];
|
||||
|
||||
if ($user) {
|
||||
$rank_sf_array = $restrict_rank ? $user->rank->subfleets()->pluck('id')->toArray() : [];
|
||||
$type_sf_array = $restrict_type ? $user->rated_subfleets->pluck('id')->toArray() : [];
|
||||
|
||||
if ($restrict_rank && !$restrict_type) {
|
||||
$restricted_to = $rank_sf_array;
|
||||
} elseif (!$restrict_rank && $restrict_type) {
|
||||
$restricted_to = $type_sf_array;
|
||||
} elseif ($restrict_rank && $restrict_type) {
|
||||
$restricted_to = array_intersect($rank_sf_array, $type_sf_array);
|
||||
}
|
||||
} else {
|
||||
$restrict_rank = false;
|
||||
$restrict_type = false;
|
||||
}
|
||||
|
||||
$subfleetsQuery = Subfleet::when($restrict_rank || $restrict_type, fn ($query) => $query->whereIn('id', $restricted_to))->with(['aircraft', 'aircraft.bid', 'fares']);
|
||||
|
||||
$mapper = function (Subfleet $sf): Subfleet {
|
||||
// @phpstan-ignore-next-line
|
||||
$sf->fares = $this->fareSvc->getForSubfleet($sf);
|
||||
|
||||
return $sf;
|
||||
};
|
||||
|
||||
// through() preserves the paginator wrapper (links + meta);
|
||||
// transform() on a non-paginated collection mutates it in place.
|
||||
return $paginate
|
||||
? $subfleetsQuery->paginate($perPage)->through($mapper)
|
||||
: $subfleetsQuery->get()->transform($mapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a bool if a user is allowed to fly the current aircraft
|
||||
*/
|
||||
public function aircraftAllowed($user, $aircraft_id): bool
|
||||
{
|
||||
$aircraft = Aircraft::findOrFail($aircraft_id, ['subfleet_id']);
|
||||
$subfleets = $this->getAllowableSubfleets($user);
|
||||
$subfleet_ids = $subfleets->pluck('id')->toArray();
|
||||
|
||||
return \in_array($aircraft->subfleet_id, $subfleet_ids, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the user's state. PENDING to ACCEPTED, etc
|
||||
* Send out an email
|
||||
|
||||
86
app/Support/SubfleetAccessPolicy.php
Normal file
86
app/Support/SubfleetAccessPolicy.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Flight;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final readonly class SubfleetAccessPolicy
|
||||
{
|
||||
public bool $rankRestricted;
|
||||
|
||||
public bool $typeRatingRestricted;
|
||||
|
||||
public bool $restrictToDepartureAirport;
|
||||
|
||||
public bool $blockBookedAircraft;
|
||||
|
||||
public function __construct(
|
||||
public User $user,
|
||||
public ?Flight $flight = null,
|
||||
) {
|
||||
$this->rankRestricted = (bool) setting('pireps.restrict_aircraft_to_rank', true);
|
||||
$this->typeRatingRestricted = (bool) setting('pireps.restrict_aircraft_to_typerating', false);
|
||||
$this->restrictToDepartureAirport = (bool) setting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
$this->blockBookedAircraft = (bool) setting('bids.block_aircraft', false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the rank + type-rating intersection to a Subfleet query.
|
||||
* No constraints are added when both restriction settings are off.
|
||||
*/
|
||||
public function applyToSubfleets(Builder $query): Builder
|
||||
{
|
||||
if ($this->rankRestricted) {
|
||||
$query->whereExists(function ($sub): void {
|
||||
$sub->select(\DB::raw(1))
|
||||
->from('subfleet_rank')
|
||||
->whereColumn('subfleet_rank.subfleet_id', 'subfleets.id')
|
||||
->where('subfleet_rank.rank_id', $this->user->rank_id);
|
||||
});
|
||||
}
|
||||
|
||||
if ($this->typeRatingRestricted) {
|
||||
$query->whereExists(function ($sub): void {
|
||||
$sub->select(\DB::raw(1))
|
||||
->from('typerating_subfleet')
|
||||
->join(
|
||||
'typerating_user',
|
||||
'typerating_user.typerating_id',
|
||||
'=',
|
||||
'typerating_subfleet.typerating_id'
|
||||
)
|
||||
->whereColumn('typerating_subfleet.subfleet_id', 'subfleets.id')
|
||||
->where('typerating_user.user_id', $this->user->id);
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply subfleet, departure-airport, and bid-block constraints to an Aircraft query.
|
||||
*/
|
||||
public function applyToAircraft(Builder $query): Builder
|
||||
{
|
||||
$query->whereHas('subfleet', fn (Builder $sub): Builder => $this->applyToSubfleets($sub));
|
||||
|
||||
if ($this->restrictToDepartureAirport && $this->flight instanceof Flight) {
|
||||
$query->where('aircraft.airport_id', $this->flight->dpt_airport_id);
|
||||
}
|
||||
|
||||
if ($this->blockBookedAircraft) {
|
||||
$query->whereNotExists(function ($sub): void {
|
||||
$sub->select(\DB::raw(1))
|
||||
->from('bids')
|
||||
->whereColumn('bids.aircraft_id', 'aircraft.id')
|
||||
->where('bids.user_id', '!=', $this->user->id);
|
||||
});
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class() extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('aircraft', function (Blueprint $table): void {
|
||||
$table->index(['subfleet_id']);
|
||||
});
|
||||
|
||||
Schema::table('bids', function (Blueprint $table): void {
|
||||
$table->index(['aircraft_id']);
|
||||
});
|
||||
|
||||
Schema::table('typerating_user', function (Blueprint $table): void {
|
||||
$table->dropIndex(['typerating_id', 'user_id']);
|
||||
$table->index(['user_id', 'typerating_id']);
|
||||
});
|
||||
|
||||
Schema::table('typerating_subfleet', function (Blueprint $table): void {
|
||||
$table->dropIndex(['typerating_id', 'subfleet_id']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('typerating_subfleet', function (Blueprint $table): void {
|
||||
$table->index(['typerating_id', 'subfleet_id']);
|
||||
});
|
||||
|
||||
Schema::table('typerating_user', function (Blueprint $table): void {
|
||||
$table->dropIndex(['user_id', 'typerating_id']);
|
||||
$table->index(['typerating_id', 'user_id']);
|
||||
});
|
||||
|
||||
Schema::table('bids', function (Blueprint $table): void {
|
||||
$table->dropIndex(['aircraft_id']);
|
||||
});
|
||||
|
||||
Schema::table('aircraft', function (Blueprint $table): void {
|
||||
$table->dropIndex(['subfleet_id']);
|
||||
});
|
||||
}
|
||||
};
|
||||
111
tests/Feature/SubfleetAccessQueryCountTest.php
Normal file
111
tests/Feature/SubfleetAccessQueryCountTest.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Airline;
|
||||
use App\Models\Airport;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Flight;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Pins the query count for the access-control hot paths. The point isn't the
|
||||
* exact number — it's that the count is O(1) wrt result-set size. If you change
|
||||
* the policy / scopes and these numbers drift, decide whether the new shape is
|
||||
* still constant-time before bumping the ceiling.
|
||||
*/
|
||||
function seedPagedFlights(int $count): array
|
||||
{
|
||||
$airport = Airport::factory()->create();
|
||||
$airline = Airline::factory()->create();
|
||||
|
||||
$subfleet = Subfleet::factory()->hasAircraft(2)->create();
|
||||
$rank = Rank::factory()->create();
|
||||
$rank->subfleets()->attach($subfleet->id);
|
||||
|
||||
$user = User::factory()->create([
|
||||
'rank_id' => $rank->id,
|
||||
'airline_id' => $airline->id,
|
||||
]);
|
||||
|
||||
$flights = collect();
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$f = Flight::factory()->create([
|
||||
'airline_id' => $airline->id,
|
||||
'dpt_airport_id' => $airport->id,
|
||||
'flight_number' => 1000 + $i,
|
||||
]);
|
||||
$f->subfleets()->attach($subfleet->id);
|
||||
$flights->push($f);
|
||||
}
|
||||
|
||||
return ['user' => $user, 'flights' => $flights, 'subfleet' => $subfleet, 'airline' => $airline];
|
||||
}
|
||||
|
||||
it('runs O(1) queries when eager-loading accessible subfleets across many flights', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', true);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user] = seedPagedFlights(25);
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$page = Flight::query()
|
||||
->withAccessibleSubfleets($user)
|
||||
->paginate(25);
|
||||
|
||||
// Force iteration so eager-loads fire
|
||||
$page->each(fn (Flight $f) => $f->subfleets->each(fn ($s) => $s->aircraft));
|
||||
|
||||
$queryCount = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
// What's load-bearing is O(1) wrt row count — not the exact integer. A 25-flight
|
||||
// page produces a constant set of queries (paginate count + select, plus eager
|
||||
// loads of subfleets + aircraft + their pivots, plus the BelongsToThrough
|
||||
// airline join the Aircraft model carries). Ceiling 16 keeps the test
|
||||
// sensitive to a regression to N+1 (which would be 25+ on this fixture)
|
||||
// without being brittle to Eloquent internals.
|
||||
expect($queryCount)->toBeLessThanOrEqual(16);
|
||||
});
|
||||
|
||||
it('runs O(1) queries for findBidsForUser-equivalent flow', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', true);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user, 'flights' => $flights, 'subfleet' => $subfleet] = seedPagedFlights(20);
|
||||
|
||||
// Create 20 bids for this user, one per flight
|
||||
$aircraftId = $subfleet->aircraft->first()->id;
|
||||
foreach ($flights as $f) {
|
||||
Bid::create([
|
||||
'user_id' => $user->id,
|
||||
'flight_id' => $f->id,
|
||||
'aircraft_id' => $aircraftId,
|
||||
]);
|
||||
}
|
||||
|
||||
DB::flushQueryLog();
|
||||
DB::enableQueryLog();
|
||||
|
||||
$bids = Bid::query()
|
||||
->where('user_id', $user->id)
|
||||
->with([
|
||||
'flight' => fn ($q) => $q->withAccessibleSubfleets($user),
|
||||
])
|
||||
->get();
|
||||
|
||||
$bids->each(fn (Bid $b) => $b->flight?->subfleets->each(fn ($s) => $s->aircraft));
|
||||
|
||||
$queryCount = count(DB::getQueryLog());
|
||||
DB::disableQueryLog();
|
||||
|
||||
expect($queryCount)->toBeLessThanOrEqual(16)
|
||||
->and($bids->count())->toBe(20);
|
||||
});
|
||||
202
tests/Feature/SubfleetAccessTest.php
Normal file
202
tests/Feature/SubfleetAccessTest.php
Normal file
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Airline;
|
||||
use App\Models\Airport;
|
||||
use App\Models\Bid;
|
||||
use App\Models\Flight;
|
||||
use App\Models\Rank;
|
||||
use App\Models\Subfleet;
|
||||
use App\Models\Typerating;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Build a deterministic fixture covering the rank/type-rating/airport matrix.
|
||||
*
|
||||
* Topology:
|
||||
* - rank R: attached to subfleets A, B
|
||||
* - typerating T1: attached to subfleets B, C
|
||||
* - typerating T2: attached to subfleet D
|
||||
* - user holds T1 only
|
||||
* - subfleet A has one aircraft at KJFK
|
||||
* - subfleet B has one aircraft at KLAX
|
||||
* - subfleet C has one aircraft at KJFK
|
||||
* - subfleet D has one aircraft at KJFK
|
||||
* - flight F departs KJFK with no pinned subfleets
|
||||
*/
|
||||
function seedAccessFixture(): array
|
||||
{
|
||||
Airport::factory()->create(['id' => 'KJFK']);
|
||||
Airport::factory()->create(['id' => 'KLAX']);
|
||||
|
||||
$sfA = Subfleet::factory()->create(['name' => 'A']);
|
||||
$sfB = Subfleet::factory()->create(['name' => 'B']);
|
||||
$sfC = Subfleet::factory()->create(['name' => 'C']);
|
||||
$sfD = Subfleet::factory()->create(['name' => 'D']);
|
||||
|
||||
$aircraftA = $sfA->aircraft()->create([
|
||||
'name' => 'AC-A', 'subfleet_id' => $sfA->id, 'airport_id' => 'KJFK',
|
||||
]);
|
||||
$aircraftB = $sfB->aircraft()->create([
|
||||
'name' => 'AC-B', 'subfleet_id' => $sfB->id, 'airport_id' => 'KLAX',
|
||||
]);
|
||||
$aircraftC = $sfC->aircraft()->create([
|
||||
'name' => 'AC-C', 'subfleet_id' => $sfC->id, 'airport_id' => 'KJFK',
|
||||
]);
|
||||
$aircraftD = $sfD->aircraft()->create([
|
||||
'name' => 'AC-D', 'subfleet_id' => $sfD->id, 'airport_id' => 'KJFK',
|
||||
]);
|
||||
|
||||
$rank = Rank::factory()->create();
|
||||
$rank->subfleets()->attach([$sfA->id, $sfB->id]);
|
||||
|
||||
$t1 = Typerating::create(['name' => 'T1', 'type' => 'T1', 'active' => 1]);
|
||||
$t2 = Typerating::create(['name' => 'T2', 'type' => 'T2', 'active' => 1]);
|
||||
$t1->subfleets()->attach([$sfB->id, $sfC->id]);
|
||||
$t2->subfleets()->attach([$sfD->id]);
|
||||
|
||||
$user = User::factory()->create(['rank_id' => $rank->id]);
|
||||
$t1->users()->attach($user->id);
|
||||
|
||||
$airline = Airline::factory()->create();
|
||||
$flight = Flight::factory()->create([
|
||||
'airline_id' => $airline->id,
|
||||
'dpt_airport_id' => 'KJFK',
|
||||
]);
|
||||
|
||||
return ['user' => $user, 'flight' => $flight, 'sfA' => $sfA, 'sfB' => $sfB, 'sfC' => $sfC, 'sfD' => $sfD, 'aircraftA' => $aircraftA, 'aircraftB' => $aircraftB, 'aircraftC' => $aircraftC, 'aircraftD' => $aircraftD];
|
||||
}
|
||||
|
||||
dataset('access matrix',
|
||||
// [rank_restrict, type_restrict, expected_subfleet_names]
|
||||
// Topology:
|
||||
// - rank attaches A, B
|
||||
// - user's typerating attaches B, C
|
||||
// - intersection: B
|
||||
// - union (neither restricted): A, B, C, D
|
||||
fn (): array => [
|
||||
'no restrictions' => [false, false, ['A', 'B', 'C', 'D']],
|
||||
'rank only' => [true, false, ['A', 'B']],
|
||||
'typerating only' => [false, true, ['B', 'C']],
|
||||
'rank AND typerating (intersect)' => [true, true, ['B']],
|
||||
]);
|
||||
|
||||
it('returns the correct allowed subfleets per setting combination', function (
|
||||
bool $rankRestrict,
|
||||
bool $typeRestrict,
|
||||
array $expectedNames,
|
||||
): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', $rankRestrict);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', $typeRestrict);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user] = seedAccessFixture();
|
||||
|
||||
$names = $user->allowedSubfleets()->pluck('name')->sort()->values()->all();
|
||||
|
||||
expect($names)->toEqual($expectedNames);
|
||||
})->with('access matrix');
|
||||
|
||||
it('applies airport restriction only when a flight context is provided', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', true);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user, 'flight' => $flight] = seedAccessFixture();
|
||||
|
||||
// No flight: all 4 aircraft visible
|
||||
$idsNoFlight = $user->allowedAircraft()->pluck('aircraft.id')->all();
|
||||
expect($idsNoFlight)->toHaveCount(4);
|
||||
|
||||
// With flight departing KJFK: AC-B (at KLAX) excluded; AC-A, AC-C, AC-D included
|
||||
$idsWithFlight = $user->allowedAircraft($flight)->pluck('aircraft.id')->all();
|
||||
expect($idsWithFlight)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('does not apply airport restriction when the setting is off', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user, 'flight' => $flight] = seedAccessFixture();
|
||||
|
||||
expect($user->allowedAircraft($flight)->count())->toEqual(4);
|
||||
});
|
||||
|
||||
it('excludes aircraft bid by another user when bid-block is on', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', true);
|
||||
|
||||
['user' => $user, 'flight' => $flight, 'aircraftA' => $aircraftA] = seedAccessFixture();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
Bid::create([
|
||||
'user_id' => $otherUser->id,
|
||||
'flight_id' => $flight->id,
|
||||
'aircraft_id' => $aircraftA->id,
|
||||
]);
|
||||
|
||||
$ids = $user->allowedAircraft()->pluck('aircraft.id')->all();
|
||||
expect($ids)->not->toContain($aircraftA->id)
|
||||
->and($ids)->toHaveCount(3);
|
||||
});
|
||||
|
||||
it('includes the requesting user own bid', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', true);
|
||||
|
||||
['user' => $user, 'flight' => $flight, 'aircraftA' => $aircraftA] = seedAccessFixture();
|
||||
|
||||
Bid::create([
|
||||
'user_id' => $user->id,
|
||||
'flight_id' => $flight->id,
|
||||
'aircraft_id' => $aircraftA->id,
|
||||
]);
|
||||
|
||||
$ids = $user->allowedAircraft()->pluck('aircraft.id')->all();
|
||||
expect($ids)->toContain($aircraftA->id);
|
||||
});
|
||||
|
||||
it('ignores bid block when the setting is off', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user, 'flight' => $flight, 'aircraftA' => $aircraftA] = seedAccessFixture();
|
||||
|
||||
$otherUser = User::factory()->create();
|
||||
Bid::create([
|
||||
'user_id' => $otherUser->id,
|
||||
'flight_id' => $flight->id,
|
||||
'aircraft_id' => $aircraftA->id,
|
||||
]);
|
||||
|
||||
expect($user->allowedAircraft()->count())->toEqual(4);
|
||||
});
|
||||
|
||||
it('combines flight-pinned subfleets via intersection with user access', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', true);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
['user' => $user, 'flight' => $flight, 'sfA' => $sfA, 'sfC' => $sfC] = seedAccessFixture();
|
||||
|
||||
// Flight pinned to A + C; user (rank) allows A + B
|
||||
$flight->subfleets()->attach([$sfA->id, $sfC->id]);
|
||||
|
||||
// Intersection: only A
|
||||
$names = Subfleet::query()
|
||||
->allowedFor($user)
|
||||
->whereHas('flights', fn ($q) => $q->whereKey($flight->id))
|
||||
->pluck('name')->all();
|
||||
|
||||
expect($names)->toEqual(['A']);
|
||||
});
|
||||
@ -21,8 +21,6 @@ use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
test('rank subfleets', function (): void {
|
||||
$userSvc = app(UserService::class);
|
||||
|
||||
// Add subfleets and aircraft, but also add another
|
||||
// set of subfleets
|
||||
$subfleet = Subfleet::factory()->hasAircraft(2)->count(2)->create();
|
||||
@ -36,7 +34,7 @@ test('rank subfleets', function (): void {
|
||||
|
||||
$added_aircraft = $subfleetA->aircraft->pluck('id');
|
||||
|
||||
$subfleets = $userSvc->getAllowableSubfleets($user);
|
||||
$subfleets = $user->allowedSubfleets()->with('aircraft')->get();
|
||||
expect($subfleets->count())->toEqual(1);
|
||||
|
||||
$subfleet = $subfleets[0];
|
||||
@ -76,7 +74,6 @@ test('rank subfleets', function (): void {
|
||||
|
||||
test('get all aircraft', function (): void {
|
||||
$fareSvc = app(FareService::class);
|
||||
$userSvc = app(UserService::class);
|
||||
|
||||
// Add subfleets and aircraft, but also add another
|
||||
// set of subfleets
|
||||
@ -110,7 +107,7 @@ test('get all aircraft', function (): void {
|
||||
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
|
||||
$subfleets = $userSvc->getAllowableSubfleets($user);
|
||||
$subfleets = $user->allowedSubfleets()->with(['aircraft', 'fares'])->get();
|
||||
expect($subfleets->count())->toEqual(2);
|
||||
|
||||
$all_aircraft = array_merge(
|
||||
@ -120,9 +117,11 @@ test('get all aircraft', function (): void {
|
||||
|
||||
expect($all_aircraft)->toEqual($added_aircraft);
|
||||
|
||||
$subfleetACalled = collect($subfleets)->firstWhere('id', $subfleetA->id);
|
||||
expect($overrides['price'])->toEqual($subfleetACalled->fares[0]['price'])
|
||||
->and($overrides['capacity'])->toEqual($subfleetACalled->fares[0]['capacity']);
|
||||
// Override resolution now happens at the API Resource boundary (not in
|
||||
// the loader). The /api/user/fleet check below pins that surface.
|
||||
$subfleetACalled = $fareSvc->getForSubfleet($subfleets->firstWhere('id', $subfleetA->id));
|
||||
expect($overrides['price'])->toEqual($subfleetACalled[0]->price)
|
||||
->and($overrides['capacity'])->toEqual($subfleetACalled[0]->capacity);
|
||||
|
||||
/**
|
||||
* Check via API, but should only show the single subfleet being returned
|
||||
|
||||
47
tests/Unit/Support/SubfleetAccessPolicyTest.php
Normal file
47
tests/Unit/Support/SubfleetAccessPolicyTest.php
Normal file
@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Flight;
|
||||
use App\Models\User;
|
||||
use App\Support\SubfleetAccessPolicy;
|
||||
|
||||
it('reads settings into typed flags', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', true);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', false);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', true);
|
||||
updateSetting('bids.block_aircraft', false);
|
||||
|
||||
$user = User::factory()->make();
|
||||
$policy = new SubfleetAccessPolicy($user);
|
||||
|
||||
expect($policy->rankRestricted)->toBeTrue()
|
||||
->and($policy->typeRatingRestricted)->toBeFalse()
|
||||
->and($policy->restrictToDepartureAirport)->toBeTrue()
|
||||
->and($policy->blockBookedAircraft)->toBeFalse();
|
||||
});
|
||||
|
||||
it('exposes the constructed user and flight', function (): void {
|
||||
$user = User::factory()->make();
|
||||
$flight = Flight::factory()->make();
|
||||
|
||||
$policy = new SubfleetAccessPolicy($user, $flight);
|
||||
|
||||
expect($policy->user)->toBe($user)
|
||||
->and($policy->flight)->toBe($flight);
|
||||
|
||||
$policyNoFlight = new SubfleetAccessPolicy($user);
|
||||
expect($policyNoFlight->flight)->toBeNull();
|
||||
});
|
||||
|
||||
it('inverts flags when settings flip', function (): void {
|
||||
updateSetting('pireps.restrict_aircraft_to_rank', false);
|
||||
updateSetting('pireps.restrict_aircraft_to_typerating', true);
|
||||
updateSetting('pireps.only_aircraft_at_dpt_airport', false);
|
||||
updateSetting('bids.block_aircraft', true);
|
||||
|
||||
$policy = new SubfleetAccessPolicy(User::factory()->make());
|
||||
|
||||
expect($policy->rankRestricted)->toBeFalse()
|
||||
->and($policy->typeRatingRestricted)->toBeTrue()
|
||||
->and($policy->restrictToDepartureAirport)->toBeFalse()
|
||||
->and($policy->blockBookedAircraft)->toBeTrue();
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user