Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/Http/Controllers/AircraftController.php
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ public function create(Request $request) {
'mtow' => 'nullable|integer|min:0|max:1000000',
'mzfw' => 'nullable|integer|min:0|max:1000000',
'mlw' => 'nullable|integer|min:0|max:1000000',
'service_ceiling' => 'nullable|integer|min:1000|max:60000',
'remarks' => 'nullable|string|max:1000',
'current_loc' => 'required|max:4',
]);
Expand Down Expand Up @@ -243,6 +244,7 @@ public function edit(Request $request, Aircraft $aircraft) {
'mtow' => 'nullable|integer|min:0|max:1000000',
'mzfw' => 'nullable|integer|min:0|max:1000000',
'mlw' => 'nullable|integer|min:0|max:1000000',
'service_ceiling' => 'nullable|integer|min:1000|max:60000',
'remarks' => 'nullable|string|max:1000',
]);

Expand All @@ -269,6 +271,7 @@ public function edit(Request $request, Aircraft $aircraft) {
$targetAircraft->mtow = $request->post('mtow');
$targetAircraft->mzfw = $request->post('mzfw');
$targetAircraft->mlw = $request->post('mlw');
$targetAircraft->service_ceiling = $request->post('service_ceiling');
$targetAircraft->status = $finalStatus;
$targetAircraft->remarks = $request->post('remarks');

Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/Api/V1/AircraftAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ public function show(Request $request, Airline $airline, Aircraft $aircraft)
* @bodyParam mtow integer Max take-off weight (kg), 0-1000000. Example: 78000
* @bodyParam mzfw integer Max zero-fuel weight (kg), 0-1000000. Example: 62500
* @bodyParam mlw integer Max landing weight (kg), 0-1000000. Example: 66000
* @bodyParam service_ceiling integer Service ceiling (ft), 1000-60000; PIREPs with a higher cruise altitude are rejected. Example: 41000
* @bodyParam remarks string Free-text remarks, max 1000 chars. Example: Delivered new.
*
* @response 201 {
Expand Down
4 changes: 2 additions & 2 deletions app/Http/Controllers/Api/V1/FlightAPIController.php
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ public function index(Request $request, Airline $airline)
* @bodyParam callsign string required Radio callsign, 1-4 digits optionally followed by up to 2 letters. Example: 421
* @bodyParam crzalt integer required Cruise altitude in feet, max 50000. Example: 36000
* @bodyParam blockoff string required Block-off time (UTC), format Y-m-d H:i:s. Example: 2026-07-11 10:00:00
* @bodyParam blockon string required Block-on time (UTC), format Y-m-d H:i:s. Example: 2026-07-11 11:30:00
* @bodyParam burned_fuel number required Fuel burned. Example: 4200
* @bodyParam blockon string required Block-on time (UTC), format Y-m-d H:i:s. Must be after blockoff; flight duration may not exceed 26 hours. Example: 2026-07-11 11:30:00
* @bodyParam burned_fuel integer required Fuel burned, in the airline's unit (min 1, max 600000). Example: 4200
* @bodyParam route string required Filed route string. Example: SOVAT UL610 KONAN
* @bodyParam online_network_id integer required Online network ID (must exist in online_networks). Example: 1
* @bodyParam remarks string Optional remarks (letters, digits, spaces, . , -). Example: Smooth flight.
Expand Down
25 changes: 24 additions & 1 deletion app/Http/Controllers/FlightController.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Notifications\PirepRejected;
use Illuminate\Support\Facades\Notification as NotificationFacade;
use App\Support\ActivityLevel;
use App\Support\FlightSanity;

class FlightController extends Controller
{
Expand Down Expand Up @@ -174,7 +175,7 @@ public function addFlight(Request $request)
"crzalt" => "numeric|max:50000|digits_between:1,5|required",
"blockoff" => "required",
"blockon" => "required",
"burned_fuel" => "numeric|required",
"burned_fuel" => "required|integer|min:" . Flight::MIN_BURNED_FUEL . "|max:" . Flight::MAX_BURNED_FUEL,
"route" => "required",
"online_network_id" => "required",
"remarks" => [
Expand All @@ -198,6 +199,17 @@ public function addFlight(Request $request)
]);
}

// Physical sanity: ordering, not-in-future, and within the max duration
$timingError = FlightSanity::timingError(
$request->post("blockoff"),
$request->post("blockon"),
);
if ($timingError !== null) {
throw ValidationException::withMessages([
"blockon" => $timingError,
]);
}

// Check if the aircraft is indeed part of the active airline AND is active
$aircraft = Aircraft::query()
->where("id", "=", $request->post("aircraft_id"))
Expand All @@ -210,6 +222,17 @@ public function addFlight(Request $request)
]);
}

// Physical sanity: cruise altitude must not exceed the airframe's service ceiling
$altitudeError = FlightSanity::altitudeError(
(int) $validated["crzalt"],
$aircraft->service_ceiling !== null ? (int) $aircraft->service_ceiling : null,
);
if ($altitudeError !== null) {
throw ValidationException::withMessages([
"crzalt" => $altitudeError,
]);
}

// Location continuity: the flight must depart from where the airframe currently is
if ($tempAirline->location_continuity
&& strtoupper($validated["departure_icao"]) !== strtoupper((string) $aircraft->current_loc)) {
Expand Down
1 change: 1 addition & 0 deletions app/Http/Requests/Api/V1/StoreAircraftRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function rules(): array
'mtow' => 'nullable|integer|min:0|max:1000000',
'mzfw' => 'nullable|integer|min:0|max:1000000',
'mlw' => 'nullable|integer|min:0|max:1000000',
'service_ceiling' => 'nullable|integer|min:1000|max:60000',
'remarks' => 'nullable|string|max:1000',
'current_loc' => 'required|max:4|exists:airports,icao_code',
];
Expand Down
22 changes: 21 additions & 1 deletion app/Http/Requests/Api/V1/StoreFlightRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use App\Models\Aircraft;
use App\Models\Airline;
use App\Models\Flight;
use App\Support\FlightSanity;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;

Expand Down Expand Up @@ -40,7 +42,7 @@ public function rules(): array
'crzalt' => 'numeric|max:50000|digits_between:1,5|required',
'blockoff' => 'required|date_format:Y-m-d H:i:s',
'blockon' => 'required|date_format:Y-m-d H:i:s',
'burned_fuel' => 'numeric|required',
'burned_fuel' => 'required|integer|min:' . Flight::MIN_BURNED_FUEL . '|max:' . Flight::MAX_BURNED_FUEL,
'route' => 'required',
'online_network_id' => 'required|exists:online_networks,id',
'remarks' => 'nullable|regex:/^[\pL\s\d\.\,\-]+$/u',
Expand Down Expand Up @@ -70,13 +72,31 @@ function (Validator $validator) {
return;
}

// Physical sanity: ordering, not-in-future, and within the max duration
$timingError = FlightSanity::timingError(
$this->input('blockoff'),
$this->input('blockon'),
);
if ($timingError !== null) {
$validator->errors()->add('blockon', $timingError);
}

$aircraft = $this->aircraft();
if ($aircraft === null) {
$validator->errors()->add('aircraft_id', 'This aircraft is not available or not owned by your airline.');

return;
}

// Physical sanity: cruise altitude must not exceed the airframe's service ceiling
$altitudeError = FlightSanity::altitudeError(
(int) $this->input('crzalt'),
$aircraft->service_ceiling !== null ? (int) $aircraft->service_ceiling : null,
);
if ($altitudeError !== null) {
$validator->errors()->add('crzalt', $altitudeError);
}

// Location continuity: the flight must depart from where the airframe currently is
if ($this->airline()->location_continuity
&& $this->input('departure_icao') !== strtoupper((string) $aircraft->current_loc)) {
Expand Down
1 change: 1 addition & 0 deletions app/Http/Resources/V1/AircraftResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function toArray(Request $request): array
'mtow' => $this->mtow,
'mzfw' => $this->mzfw,
'mlw' => $this->mlw,
'service_ceiling' => $this->service_ceiling,
'remarks' => $this->remarks,

// We don't have to return this, since this can only be accessed through a route, which already specifies the airline.
Expand Down
1 change: 1 addition & 0 deletions app/Models/Aircraft.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class Aircraft extends Model
'mtow',
'mzfw',
'mlw',
'service_ceiling',
'remarks',
'current_loc',
'used_by',
Expand Down
18 changes: 18 additions & 0 deletions app/Models/Flight.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ class Flight extends Model
{
use HasFactory, LogsModelActivity;

/**
* Physical sanity bounds enforced when a PIREP is filed. These are universal
* aviation limits, not per-instance policy, so they live as constants.
*
* The longest airliner flight on record is the Boeing 777-200LR ferry (2005,
* ~22h42m airborne); ~23h airborne + taxi, rounded up with a safety net.
*/
public const MAX_DURATION_MINUTES = 26 * 60;

/** Fuel must be positive; 0/negative is nonsense. */
public const MIN_BURNED_FUEL = 1;

/**
* Generous upper ceiling to catch typos in either unit: an A380 holds
* ~256 t (~564,000 lb) of fuel, so this clears every real airframe.
*/
public const MAX_BURNED_FUEL = 600000;

protected $fillable = [
'airline_id',
'callsign',
Expand Down
85 changes: 85 additions & 0 deletions app/Support/FlightSanity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

namespace App\Support;

use App\Models\Flight;
use Carbon\Carbon;
use Carbon\Exceptions\InvalidFormatException;

/**
* Physics-grounded sanity checks shared by both PIREP filing paths (the web
* FlightController and the API StoreFlightRequest), so the rules live in one
* place. Fuel bounds are plain field rules; the timing and altitude checks are
* cross-field / model-aware and live here.
*/
class FlightSanity
{
/**
* Small grace to absorb clock skew between an ACARS/browser client and the
* server (and the minute-truncated form prefill). Gross future timestamps -
* the "planning a flight that hasn't happened" case - are still rejected.
*/
private const FUTURE_TOLERANCE_MINUTES = 5;

/**
* Validate the block-off/block-on pair. Returns a human-readable error when
* the pair is invalid, or null when it is within bounds. Checked in order:
* block-on strictly after block-off, block-on not in the future (a PIREP
* records a flight that already happened - YAAMS has no flight planning),
* and the duration within Flight::MAX_DURATION_MINUTES.
*
* Flight times are Zulu (UTC), so both the input and "now" are compared in
* UTC. Both the API (`Y-m-d H:i:s`) and the web `datetime-local` input
* (`Y-m-d\TH:i`, no seconds) formats are accepted via Carbon::parse().
*/
public static function timingError(?string $blockoff, ?string $blockon): ?string
{
if ($blockoff === null || $blockon === null) {
return null;
}

try {
$off = Carbon::parse($blockoff, 'UTC');
$on = Carbon::parse($blockon, 'UTC');
} catch (InvalidFormatException) {
// Unparseable timestamps are the field rules' job to report.
return null;
}

if ($on->lessThanOrEqualTo($off)) {
return 'Block on time must be after block off time.';
}

if ($on->greaterThan(Carbon::now('UTC')->addMinutes(self::FUTURE_TOLERANCE_MINUTES))) {
return 'Block on time cannot be in the future - a PIREP records a flight that already happened.';
}

$maxHours = intdiv(Flight::MAX_DURATION_MINUTES, 60);

if ($off->diffInMinutes($on) > Flight::MAX_DURATION_MINUTES) {
return "Flight duration exceeds the maximum of {$maxHours} hours.";
}

return null;
}

/**
* Reject a cruise altitude above the aircraft's service ceiling. The ceiling
* is optional per airframe (nullable column): when it is not set, no
* per-aircraft limit applies and only the global crzalt field rule governs.
* Returns a human-readable error or null.
*/
public static function altitudeError(?int $crzalt, ?int $serviceCeiling): ?string
{
if ($crzalt === null || $serviceCeiling === null) {
return null;
}

if ($crzalt > $serviceCeiling) {
return 'Cruise altitude exceeds the service ceiling of this aircraft ('
. number_format($serviceCeiling) . ' ft).';
}

return null;
}
}
9 changes: 9 additions & 0 deletions database/factories/AircraftFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,18 @@ public function definition(): array
'current_loc' => fn () => Airport::factory()->create()->icao_code,
'used_by' => Airline::factory(),
'status' => Aircraft::STATUS_ACTIVE,
'service_ceiling' => 41000,
];
}

/**
* Pin the service ceiling (feet).
*/
public function serviceCeiling(int $ft): static
{
return $this->state(fn () => ['service_ceiling' => $ft]);
}

/**
* Park the airframe at a specific, already-existing airport.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?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) {
// Service ceiling in feet; nullable - when unset, no per-aircraft
// altitude limit is enforced on PIREP filing.
$table->integer('service_ceiling')->nullable()->after('mlw');
});
}

public function down(): void
{
Schema::table('aircraft', function (Blueprint $table) {
$table->dropColumn('service_ceiling');
});
}
};
10 changes: 10 additions & 0 deletions resources/views/fleet/create.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,16 @@
<div class="form-text fs-7">Maximum Landing Weight</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label for="service_ceiling" class="form-label fw-semibold">Service Ceiling <span class="text-muted">(Optional)</span></label>
<div class="input-group">
<input type="number" class="form-control" id="service_ceiling" name="service_ceiling" value="{{ old('service_ceiling') }}" min="1000" max="60000" placeholder="e.g. 41000">
<span class="input-group-text bg-light text-muted">ft</span>
</div>
<div class="form-text fs-7">Max cruise altitude; PIREPs above it are rejected</div>
</div>
</div>
</div>
</div>
</div>
Expand Down
6 changes: 6 additions & 0 deletions resources/views/fleet/detail.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,12 @@
{{ $aircraft->mlw ? number_format($aircraft->mlw) . ' kg' : 'N/A' }}
</td>
</tr>
<tr>
<td class="text-muted ps-4 py-2.5">Service Ceiling</td>
<td class="fw-semibold text-dark py-2.5" colspan="5">
{{ $aircraft->service_ceiling ? number_format($aircraft->service_ceiling) . ' ft' : 'N/A' }}
</td>
</tr>
</tbody>
</table>
</div>
Expand Down
10 changes: 10 additions & 0 deletions resources/views/fleet/edit.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,16 @@
<div class="form-text fs-7">Maximum Landing Weight</div>
</div>
</div>
<div class="row g-3 mt-1">
<div class="col-md-4">
<label for="service_ceiling" class="form-label fw-semibold">Service Ceiling <span class="text-muted">(Optional)</span></label>
<div class="input-group">
<input type="number" class="form-control" id="service_ceiling" name="service_ceiling" value="{{ old('service_ceiling', $aircraft->service_ceiling) }}" min="1000" max="60000" placeholder="e.g. 41000">
<span class="input-group-text bg-light text-muted">ft</span>
</div>
<div class="form-text fs-7">Max cruise altitude; PIREPs above it are rejected</div>
</div>
</div>
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion resources/views/flights/add.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
<div class="col-md-4">
@php $isLbs = session('activeairline')->unit_is_lbs; @endphp
<label for="burned_fuel" class="form-label">Burned Fuel ({{ $isLbs ? 'LBS' : 'KG' }})</label>
<input type="number" class="form-control font-monospace" min="0" placeholder="{{ $isLbs ? '36000' : '5900' }}" required name="burned_fuel" id="burned_fuel" value="{{ old('burned_fuel') }}">
<input type="number" class="form-control font-monospace" min="1" placeholder="{{ $isLbs ? '36000' : '5900' }}" required name="burned_fuel" id="burned_fuel" value="{{ old('burned_fuel') }}">
</div>
</div>
</div>
Expand Down
Loading
Loading