feat(flights): with=bid fast-path + by-id search visibility

Add a controlled `bid` token to the flights get()/search() `with=` param.
When present, resolve the authenticated pilot's bid(s) on the flight, load
`bid.aircraft.subfleet.fares`, set `subfleets` to just those subfleet(s), and
skip accessibleSubfleetsFor entirely. No bid -> empty subfleets. `bid` is a
server-controlled whitelist token, never passed to Eloquent ->with().

search() also passes onlyActive = !filled('flight_id') so a keyed by-id lookup
returns the flight regardless of `visible` (parity with get()); browse search
still applies visible().

Cuts a bid briefing from expanding the whole accessible fleet (~234 subfleets on
the live VA) to ~5 indexed queries.
This commit is contained in:
Nabeel Shahzad 2026-07-06 15:54:08 -05:00
parent 4c9a67c2e5
commit 36b5ead2d5
3 changed files with 305 additions and 7 deletions

View File

@ -10,6 +10,7 @@ use App\Http\Requests\SearchFlightsRequest;
use App\Http\Resources\FlightResource;
use App\Http\Resources\NavdataResource;
use App\Models\Aircraft;
use App\Models\Bid;
use App\Models\Flight;
use App\Models\SimBrief;
use App\Models\User;
@ -40,7 +41,7 @@ class FlightController extends Controller
return $this->search($request);
}
public function get(string $id): FlightResource
public function get(string $id, Request $request): FlightResource
{
/** @var User $user */
$user = Auth::user();
@ -55,10 +56,14 @@ class FlightController extends Controller
])
->findOrFail($id);
if ($this->hasBidToken($request)) {
$this->loadBidSubfleets($flight, $user->id);
} else {
$flight->setRelation(
'subfleets',
$flight->accessibleSubfleetsFor($user, ['aircraft', 'fares']),
);
}
$flight = $this->fareSvc->getReconciledFaresForFlight($flight);
@ -70,7 +75,9 @@ class FlightController extends Controller
/** @var User $user */
$user = Auth::user();
$query = $this->flightSearchQuery->build($request)
$onlyActive = !$request->filled('flight_id');
$query = $this->flightSearchQuery->build($request, $onlyActive)
->whereHas('airline', function ($q): void {
$q->where('active', true);
});
@ -103,9 +110,11 @@ class FlightController extends Controller
$relations = explode(',', (string) $request->input('with', ''));
}
$withBid = in_array('bid', $relations, true);
$query->with($with);
if (in_array('subfleets', $relations, true)) {
if (!$withBid && in_array('subfleets', $relations, true)) {
$query->withAccessibleSubfleets($user);
}
@ -113,12 +122,51 @@ class FlightController extends Controller
$flights = $query->paginate($perPage);
foreach ($flights as $flight) {
if ($withBid) {
$this->loadBidSubfleets($flight, $user->id);
}
$this->fareSvc->getReconciledFaresForFlight($flight);
}
return FlightResource::collection($flights);
}
/**
* Check whether the request carries the controlled `bid` token in `?with=`.
*/
private function hasBidToken(Request $request): bool
{
if (!$request->has('with')) {
return false;
}
$tokens = array_map(trim(...), explode(',', (string) $request->input('with', '')));
return in_array('bid', $tokens, true);
}
/**
* Resolve the authenticated user's bid(s) on the given flight, load
* `bid.aircraft.subfleet.fares`, and set the flight's `subfleets` relation
* to exactly those subfleet(s). No fleet expansion is performed.
* A pilot with no bid gets an empty `subfleets` collection.
*/
private function loadBidSubfleets(Flight $flight, int $userId): void
{
$bids = Bid::where('flight_id', $flight->id)
->where('user_id', $userId)
->with('aircraft.subfleet.fares')
->get();
$subfleets = $bids->pluck('aircraft.subfleet')
->filter()
->unique('id')
->values();
$flight->setRelation('subfleets', $subfleets);
}
/**
* Output the flight briefing from simbrief or whatever other format
*

View File

@ -526,6 +526,11 @@ class Flight extends Model
return $this->hasMany(FlightFieldValue::class, 'flight_id', 'id');
}
public function bids(): HasMany
{
return $this->hasMany(Bid::class, 'flight_id');
}
public function simbrief(): BelongsTo
{
return $this->belongsTo(SimBrief::class, 'id', 'flight_id');

View File

@ -0,0 +1,245 @@
<?php
use App\Models\Aircraft;
use App\Models\Bid;
use App\Models\Fare;
use App\Models\Flight;
use App\Models\Subfleet;
use App\Models\User;
use App\Services\FareService;
// ---------------------------------------------------------------------------
// get() with=bid
// ---------------------------------------------------------------------------
test('get with bid token returns only bid subfleet and reconciled fares', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
$fareSvc = app(FareService::class);
$user = User::factory()->create();
apiAs($user);
$subfleet = Subfleet::factory()->create();
$aircraft = Aircraft::factory()->create(['subfleet_id' => $subfleet->id]);
$fare = Fare::factory()->create();
// Subfleet-level fare override — this is what the API returns (SubfleetResource applies
// subfleet pivot; flight-level overrides are recomputed at PIREP-file time).
$fareSvc->setForSubfleet($subfleet, $fare, ['price' => 100, 'capacity' => 50]);
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
$flight->subfleets()->attach($subfleet->id);
Bid::create([
'user_id' => $user->id,
'flight_id' => $flight->id,
'aircraft_id' => $aircraft->id,
]);
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
$res->assertStatus(200);
$body = $res->json()['data'];
// Subfleet is the bid's subfleet; fare is present with the subfleet-level price.
expect($body['subfleets'])->toHaveCount(1)
->and($body['subfleets'][0]['id'])->toBe($subfleet->id)
->and($body['subfleets'][0]['fares'])->toHaveCount(1)
->and((float) $body['subfleets'][0]['fares'][0]['price'])->toEqual(100.0);
});
test('get with bid token returns empty subfleets when pilot has no bid', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
$user = User::factory()->create();
apiAs($user);
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
$res->assertStatus(200);
expect($res->json()['data']['subfleets'])->toBeEmpty();
});
test('get with bid token only shows the authenticated pilots own bid subfleet', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
$pilotA = User::factory()->create();
$pilotB = User::factory()->create();
$subfleetA = Subfleet::factory()->create();
$aircraftA = Aircraft::factory()->create(['subfleet_id' => $subfleetA->id]);
$subfleetB = Subfleet::factory()->create();
$aircraftB = Aircraft::factory()->create(['subfleet_id' => $subfleetB->id]);
$flight = Flight::factory()->create(['airline_id' => $pilotA->airline_id]);
Bid::create(['user_id' => $pilotA->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraftA->id]);
Bid::create(['user_id' => $pilotB->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraftB->id]);
apiAs($pilotA);
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
$res->assertStatus(200);
$subfleets = $res->json()['data']['subfleets'];
expect($subfleets)->toHaveCount(1)
->and($subfleets[0]['id'])->toBe($subfleetA->id);
});
test('get without bid token performs full accessible fleet expansion', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
$user = User::factory()->create();
apiAs($user);
$subfleet1 = Subfleet::factory()->create();
$subfleet2 = Subfleet::factory()->create();
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
$flight->subfleets()->attach([$subfleet1->id, $subfleet2->id]);
$res = $this->get('/api/flights/'.$flight->id);
$res->assertStatus(200);
// Both pinned subfleets returned via accessibleSubfleetsFor (fleet expansion path)
expect($res->json()['data']['subfleets'])->toHaveCount(2);
});
test('get with bid token skips accessible fleet expansion (behavioral proof)', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
$user = User::factory()->create();
apiAs($user);
// Two subfleets both pinned to flight
$subfleet1 = Subfleet::factory()->create();
$subfleet2 = Subfleet::factory()->create();
$aircraft1 = Aircraft::factory()->create(['subfleet_id' => $subfleet1->id]);
$flight = Flight::factory()->create(['airline_id' => $user->airline_id]);
$flight->subfleets()->attach([$subfleet1->id, $subfleet2->id]);
// Bid only on subfleet1's aircraft
Bid::create(['user_id' => $user->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraft1->id]);
$res = $this->get('/api/flights/'.$flight->id.'?with=bid');
$res->assertStatus(200);
// Fleet expansion would return 2 subfleets; bid fast-path returns only 1
$subfleets = $res->json()['data']['subfleets'];
expect($subfleets)->toHaveCount(1)
->and($subfleets[0]['id'])->toBe($subfleet1->id);
});
// ---------------------------------------------------------------------------
// search() with=bid
// ---------------------------------------------------------------------------
test('search with bid token decorates bid flights with subfleet and empty for non-bid flights', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
updateSetting('pilots.restrict_to_company', false);
updateSetting('pilots.only_flights_from_current', false);
$user = User::factory()->create();
apiAs($user);
$subfleet = Subfleet::factory()->create();
$aircraft = Aircraft::factory()->create(['subfleet_id' => $subfleet->id]);
$flight1 = Flight::factory()->create(['airline_id' => $user->airline_id]);
$flight2 = Flight::factory()->create(['airline_id' => $user->airline_id]);
Bid::create(['user_id' => $user->id, 'flight_id' => $flight1->id, 'aircraft_id' => $aircraft->id]);
$res = $this->get('/api/flights/search?with=bid');
$res->assertStatus(200);
$data = collect($res->json()['data']);
$f1 = $data->firstWhere('id', $flight1->id);
$f2 = $data->firstWhere('id', $flight2->id);
expect($f1)->not()->toBeNull()
->and($f1['subfleets'])->toHaveCount(1)
->and($f1['subfleets'][0]['id'])->toBe($subfleet->id);
expect($f2)->not()->toBeNull()
->and($f2['subfleets'])->toBeEmpty();
});
// ---------------------------------------------------------------------------
// By-id search visibility
// ---------------------------------------------------------------------------
test('search with flight_id returns invisible flight', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
$user = User::factory()->create();
apiAs($user);
$flight = Flight::factory()->create([
'airline_id' => $user->airline_id,
'visible' => false,
]);
$res = $this->get('/api/flights/search?flight_id='.$flight->id);
$res->assertStatus(200);
$data = $res->json()['data'];
expect($data)->toHaveCount(1)
->and($data[0]['id'])->toBe($flight->id);
});
test('browse search without flight_id hides invisible flights', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pilots.restrict_to_company', false);
updateSetting('pilots.only_flights_from_current', false);
$user = User::factory()->create();
apiAs($user);
$visible = Flight::factory()->create(['airline_id' => $user->airline_id, 'visible' => true]);
$invisible = Flight::factory()->create(['airline_id' => $user->airline_id, 'visible' => false]);
$res = $this->get('/api/flights/search');
$res->assertStatus(200);
$ids = collect($res->json()['data'])->pluck('id');
expect($ids)->toContain($visible->id)
->and($ids)->not()->toContain($invisible->id);
});
test('search by flight_id with bid token returns invisible flight with bid subfleets', function (): void {
updateSetting('pireps.restrict_aircraft_to_rank', false);
updateSetting('pireps.restrict_aircraft_to_typerating', false);
$user = User::factory()->create();
apiAs($user);
$subfleet = Subfleet::factory()->create();
$aircraft = Aircraft::factory()->create(['subfleet_id' => $subfleet->id]);
$flight = Flight::factory()->create([
'airline_id' => $user->airline_id,
'visible' => false,
]);
Bid::create(['user_id' => $user->id, 'flight_id' => $flight->id, 'aircraft_id' => $aircraft->id]);
$res = $this->get('/api/flights/search?flight_id='.$flight->id.'&with=bid');
$res->assertStatus(200);
$data = $res->json()['data'];
expect($data)->toHaveCount(1)
->and($data[0]['id'])->toBe($flight->id)
->and($data[0]['subfleets'])->toHaveCount(1)
->and($data[0]['subfleets'][0]['id'])->toBe($subfleet->id);
});