From fa49bfcc537e0247b2f6c603087ff8d25cb54279 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Fri, 17 Apr 2026 12:44:54 +0100 Subject: [PATCH 01/20] Add VAA stand reservation plan endpoint Introduce an endpoint to accept VAA stand reservation plan submissions. Adds StandReservationPlanController@store, a FormRequest (StoreStandReservationPlan) that restricts submissions to VAA users, and a comprehensive validation rule (StandReservationPlanPayload) which enforces strict JSON schema, Zulu timestamps, CID/airport/stand rules, and rejects overlapping reservations for the same stand. Registers the route POST /stand/reservation-plan, adds documentation (docs/guides/VaaStandReservationPlans.md) and links it from the StandAllocation guide, and includes controller tests to cover successful submission, authorization, overlap detection, and multi-airport validation. On success the API creates a plan with status=submitted and returns 201 with id and status. --- .../StandReservationPlanController.php | 36 +++ .../Stand/StoreStandReservationPlan.php | 27 ++ .../Stand/StandReservationPlanPayload.php | 282 ++++++++++++++++++ docs/guides/StandAllocation.md | 5 + docs/guides/VaaStandReservationPlans.md | 127 ++++++++ routes/api.php | 1 + .../StandReservationPlanControllerTest.php | 157 ++++++++++ 7 files changed, 635 insertions(+) create mode 100644 app/Http/Controllers/StandReservationPlanController.php create mode 100644 app/Http/Requests/Stand/StoreStandReservationPlan.php create mode 100644 app/Rules/Stand/StandReservationPlanPayload.php create mode 100644 docs/guides/VaaStandReservationPlans.md create mode 100644 tests/app/Http/Controllers/StandReservationPlanControllerTest.php diff --git a/app/Http/Controllers/StandReservationPlanController.php b/app/Http/Controllers/StandReservationPlanController.php new file mode 100644 index 000000000..d6c3f0beb --- /dev/null +++ b/app/Http/Controllers/StandReservationPlanController.php @@ -0,0 +1,36 @@ +validated(); + + $plan = StandReservationPlan::create( + [ + 'name' => $validated['name'], + 'contact_email' => $validated['contact_email'], + 'payload' => $validated['payload'], + 'submitted_by' => $request->user()?->id, + 'submitted_at' => Carbon::now(), + 'status' => StandReservationPlanStatus::SUBMITTED, + ] + ); + + return response()->json( + [ + 'id' => $plan->id, + 'status' => $plan->status->value, + ], + 201 + ); + } +} diff --git a/app/Http/Requests/Stand/StoreStandReservationPlan.php b/app/Http/Requests/Stand/StoreStandReservationPlan.php new file mode 100644 index 000000000..5c363121c --- /dev/null +++ b/app/Http/Requests/Stand/StoreStandReservationPlan.php @@ -0,0 +1,27 @@ +user()?->hasRole(RoleKeys::VAA); + } + + /** + * @return array|string> + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'contact_email' => ['required', 'email:rfc', 'max:255'], + 'payload' => ['required', 'array', new StandReservationPlanPayload()], + ]; + } +} diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php new file mode 100644 index 000000000..a13636222 --- /dev/null +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -0,0 +1,282 @@ +validateUnknownKeys($attribute, $value, ['event_start', 'event_end', 'event_airport', 'event_airports', 'reservations'], $fail); + + $eventStart = $this->parseZuluTime($value['event_start'] ?? null); + if (!$eventStart) { + $fail("$attribute.event_start must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + $eventEnd = $this->parseZuluTime($value['event_end'] ?? null); + if (!$eventEnd) { + $fail("$attribute.event_end must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + if ($eventStart && $eventEnd && !$eventEnd->isAfter($eventStart)) { + $fail("$attribute.event_end must be after event_start."); + } + + $eventAirports = $this->validateEventAirportScope($attribute, $value, $fail); + + if (!array_key_exists('reservations', $value) || !is_array($value['reservations']) || count($value['reservations']) === 0) { + $fail("$attribute.reservations must be a non-empty array."); + return; + } + + $intervalsByStand = []; + + foreach ($value['reservations'] as $index => $reservation) { + $itemPath = "$attribute.reservations.$index"; + + if (!is_array($reservation)) { + $fail("$itemPath must be an object."); + continue; + } + + $this->validateUnknownKeys($itemPath, $reservation, ['stand_id', 'stand', 'airport', 'cid', 'timefrom', 'timeto'], $fail); + + $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; + $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; + + if ($standIdProvided === $standProvided) { + $fail("$itemPath must include exactly one of stand_id or stand."); + continue; + } + + if (!$this->isValidCid($reservation['cid'] ?? null)) { + $fail("$itemPath.cid must be a valid VATSIM CID."); + } + + $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); + if (!$timeFrom) { + $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); + if (!$timeTo) { + $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { + $fail("$itemPath.timeto must be after timefrom."); + } + + if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { + $fail("$itemPath.timefrom must be within the event window."); + } + + if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { + $fail("$itemPath.timeto must be within the event window."); + } + + $standKey = null; + if ($standIdProvided) { + if (!$this->isPositiveInteger($reservation['stand_id'])) { + $fail("$itemPath.stand_id must be a positive integer."); + } else { + $standKey = sprintf('id:%d', $reservation['stand_id']); + } + } + + if ($standProvided) { + if (!is_string($reservation['stand']) || trim($reservation['stand']) === '') { + $fail("$itemPath.stand must be a non-empty string."); + } + + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + + if (is_null($resolvedAirport)) { + if (count($eventAirports) === 1) { + $resolvedAirport = $eventAirports[0]; + } else { + $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); + } + } + + if ($resolvedAirport && is_string($reservation['stand']) && trim($reservation['stand']) !== '') { + $standKey = sprintf( + 'code:%s:%s', + $resolvedAirport, + strtoupper(trim($reservation['stand'])) + ); + } + } + + if ($standKey && $timeFrom && $timeTo) { + $intervalsByStand[$standKey][] = [ + 'index' => $index, + 'from' => $timeFrom, + 'to' => $timeTo, + ]; + } + } + + $this->validateStandIntervalOverlaps($attribute, $intervalsByStand, $fail); + } + + /** + * @param string $path + * @param array $value + * @param array $allowedKeys + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return void + */ + private function validateUnknownKeys(string $path, array $value, array $allowedKeys, Closure $fail): void + { + $unknownKeys = array_diff(array_keys($value), $allowedKeys); + + foreach ($unknownKeys as $unknownKey) { + $fail("$path.$unknownKey is not an allowed field."); + } + } + + /** + * @param string $attribute + * @param array $payload + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array + */ + private function validateEventAirportScope(string $attribute, array $payload, Closure $fail): array + { + $hasSingleAirport = array_key_exists('event_airport', $payload); + $hasMultipleAirports = array_key_exists('event_airports', $payload); + + if ($hasSingleAirport === $hasMultipleAirports) { + $fail("$attribute must include exactly one of event_airport or event_airports."); + return []; + } + + if ($hasSingleAirport) { + $airport = $this->normalizeAirportCode($payload['event_airport']); + + if (!$airport) { + $fail("$attribute.event_airport must be a 4-letter ICAO code."); + return []; + } + + return [$airport]; + } + + if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { + $fail("$attribute.event_airports must be a non-empty array of 4-letter ICAO codes."); + return []; + } + + $airports = []; + foreach ($payload['event_airports'] as $index => $airport) { + $normalizedAirport = $this->normalizeAirportCode($airport); + if (!$normalizedAirport) { + $fail("$attribute.event_airports.$index must be a 4-letter ICAO code."); + continue; + } + + $airports[] = $normalizedAirport; + } + + if (count(array_unique($airports)) !== count($airports)) { + $fail("$attribute.event_airports must not contain duplicate airports."); + } + + return $airports; + } + + private function normalizeAirportCode(mixed $value): ?string + { + if (!is_string($value)) { + return null; + } + + $airport = strtoupper(trim($value)); + + if (!preg_match('/^[A-Z]{4}$/', $airport)) { + return null; + } + + return $airport; + } + + private function parseZuluTime(mixed $value): ?CarbonImmutable + { + if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $value)) { + return null; + } + + try { + $parsed = CarbonImmutable::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC'); + } catch (\Exception) { + return null; + } + + if (!$parsed || $parsed->format('Y-m-d\TH:i:s\Z') !== $value) { + return null; + } + + return $parsed; + } + + private function isPositiveInteger(mixed $value): bool + { + return is_int($value) && $value > 0; + } + + private function isValidCid(mixed $value): bool + { + return is_int($value) && VatsimCidValidator::isValid($value); + } + + /** + * @param string $attribute + * @param array> $intervalsByStand + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return void + */ + private function validateStandIntervalOverlaps(string $attribute, array $intervalsByStand, Closure $fail): void + { + foreach ($intervalsByStand as $standIntervals) { + usort( + $standIntervals, + fn (array $left, array $right): int => $left['from']->getTimestamp() <=> $right['from']->getTimestamp() + ); + + for ($i = 1; $i < count($standIntervals); $i++) { + $previous = $standIntervals[$i - 1]; + $current = $standIntervals[$i]; + + if ($current['from']->isBefore($previous['to'])) { + $fail( + sprintf( + '%s.reservations.%d overlaps with reservations.%d for the same stand.', + $attribute, + $current['index'], + $previous['index'] + ) + ); + } + } + } + } +} diff --git a/docs/guides/StandAllocation.md b/docs/guides/StandAllocation.md index 6fd6c10ad..79856cd53 100644 --- a/docs/guides/StandAllocation.md +++ b/docs/guides/StandAllocation.md @@ -5,6 +5,11 @@ Whilst the final decision on where aircraft should park belongs to the controlle a realistic stand is assigned to each flight, based on a number of parameters. This is a highly complex system, so this guide is intended to explain how it all works under the hood. +## Related guide + +For VAA event stand reservation plan payloads and JSON validation requirements, see +`docs/guides/VaaStandReservationPlans.md`. + # Stand Occupation Every minute, the system will look at aircraft currently on the ground at UK airports. If an aircraft is deemed to be within diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md new file mode 100644 index 000000000..fc72f0f16 --- /dev/null +++ b/docs/guides/VaaStandReservationPlans.md @@ -0,0 +1,127 @@ +# VAA Stand Reservation Plan JSON Guide + +This guide explains how Virtual Airline Administrators (VAAs) should build JSON payloads for stand reservation plans. + +All times must be in Zulu (UTC) format: + +- `YYYY-MM-DDTHH:MM:SSZ` +- Example: `2026-06-12T14:30:00Z` + +## Top-Level Schema + +A stand reservation plan submission contains: + +- `name` (string, required): Human-readable plan name. +- `contact_email` (string, required): Contact for validation/import questions. +- `payload` (object, required): Event metadata and reservations. + +`payload` fields: + +- `event_start` (string, required): Event start in Zulu. +- `event_end` (string, required): Event end in Zulu and after `event_start`. +- Exactly one of: +- `event_airport` (string, required if single-airport event): 4-letter ICAO. +- `event_airports` (array of strings, required if multi-airport event): non-empty, unique 4-letter ICAOs. +- `reservations` (array, required): One or more reservation objects. + +## Reservation Schema + +Each item in `payload.reservations` must include: + +- `cid` (integer, required): Valid VATSIM CID. +- `timefrom` (string, required): Reservation start in Zulu. +- `timeto` (string, required): Reservation end in Zulu and after `timefrom`. +- Exactly one stand reference mode: +- `stand_id` (integer, required if using stand ID mode) +- `stand` (string, required if using stand identifier mode) + +Optional field: + +- `airport` (string): 4-letter ICAO for stand identifier mode. + +`airport` inference rule: + +- If you use `stand` and the event has one airport (`event_airport`), `airport` may be omitted. +- If you use `stand` and the event has multiple airports (`event_airports`), `airport` is required per reservation. + +## Validation Rules + +The server applies these rules: + +- Unknown fields are rejected (strict schema). +- Reservation times must be inside the event window (`event_start` to `event_end`). +- Multiple stands can be included in one plan. +- The same stand can be reused at different times if time windows do not overlap. +- Overlapping reservations for the same stand are rejected. + +## Valid Example (Multi-Stand, Reused Stand) + +```json +{ + "name": "Summer Fly-In Plan", + "contact_email": "ops@example.org", + "payload": { + "event_start": "2026-06-12T08:00:00Z", + "event_end": "2026-06-12T20:00:00Z", + "event_airports": ["EGLL", "EGKK"], + "reservations": [ + { + "stand_id": 1201, + "cid": 1203533, + "timefrom": "2026-06-12T08:00:00Z", + "timeto": "2026-06-12T10:00:00Z" + }, + { + "stand_id": 1201, + "cid": 1203534, + "timefrom": "2026-06-12T10:15:00Z", + "timeto": "2026-06-12T12:00:00Z" + }, + { + "airport": "EGLL", + "stand": "A23", + "cid": 1203535, + "timefrom": "2026-06-12T09:30:00Z", + "timeto": "2026-06-12T11:00:00Z" + }, + { + "airport": "EGKK", + "stand": "55", + "cid": 1203536, + "timefrom": "2026-06-12T13:00:00Z", + "timeto": "2026-06-12T15:30:00Z" + } + ] + } +} +``` + +## Invalid Example (Overlapping Same Stand) + +```json +{ + "name": "Invalid Overlap", + "contact_email": "ops@example.org", + "payload": { + "event_start": "2026-06-12T08:00:00Z", + "event_end": "2026-06-12T20:00:00Z", + "event_airport": "EGLL", + "reservations": [ + { + "stand_id": 1201, + "cid": 1203533, + "timefrom": "2026-06-12T10:00:00Z", + "timeto": "2026-06-12T11:00:00Z" + }, + { + "stand_id": 1201, + "cid": 1203534, + "timefrom": "2026-06-12T10:30:00Z", + "timeto": "2026-06-12T11:30:00Z" + } + ] + } +} +``` + +This is rejected because the same stand has overlapping periods. diff --git a/routes/api.php b/routes/api.php index d7df963b5..8dddc3085 100644 --- a/routes/api.php +++ b/routes/api.php @@ -93,6 +93,7 @@ function () { Route::post('stand/assignment/requestauto', 'StandController@requestAutomaticStandAssignment'); Route::delete('stand/assignment/{callsign}', 'StandController@deleteStandAssignment') ->where('callsign', VatsimCallsign::CALLSIGN_REGEX); + Route::post('stand/reservation-plan', 'StandReservationPlanController@store'); // Notifications Route::get('notifications', 'NotificationController@getActiveNotifications'); diff --git a/tests/app/Http/Controllers/StandReservationPlanControllerTest.php b/tests/app/Http/Controllers/StandReservationPlanControllerTest.php new file mode 100644 index 000000000..04f3fefff --- /dev/null +++ b/tests/app/Http/Controllers/StandReservationPlanControllerTest.php @@ -0,0 +1,157 @@ +roles()->sync([Role::idFromKey(RoleKeys::VAA)]); + } + + public function testItStoresAValidStandReservationPlan(): void + { + $payload = [ + 'name' => 'Heathrow Summer Event', + 'contact_email' => 'ops@example.org', + 'payload' => [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EGLL', + 'reservations' => [ + [ + 'stand_id' => 1, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T08:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + [ + 'stand' => '251', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T10:15:00Z', + 'timeto' => '2026-06-12T12:00:00Z', + ], + ], + ], + ]; + + $this->makeAuthenticatedApiRequest(self::METHOD_POST, 'stand/reservation-plan', $payload) + ->assertStatus(201) + ->assertJsonStructure(['id', 'status']) + ->assertJson(['status' => 'submitted']); + + $this->assertDatabaseHas( + 'stand_reservation_plans', + [ + 'name' => 'Heathrow Summer Event', + 'contact_email' => 'ops@example.org', + 'submitted_by' => UserTableSeeder::ACTIVE_USER_CID, + 'status' => 'submitted', + ] + ); + } + + public function testItRejectsNonVaaUser(): void + { + $user = User::findOrFail(UserTableSeeder::ACTIVE_USER_CID); + $user->roles()->sync([]); + + $response = $this->makeAuthenticatedApiRequest( + self::METHOD_POST, + 'stand/reservation-plan', + [ + 'name' => 'Heathrow Summer Event', + 'contact_email' => 'ops@example.org', + 'payload' => [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EGLL', + 'reservations' => [], + ], + ] + ); + + $response->assertStatus(403); + } + + public function testItRejectsOverlappingReservationsForTheSameStand(): void + { + $payload = [ + 'name' => 'Overlap Test', + 'contact_email' => 'ops@example.org', + 'payload' => [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EGLL', + 'reservations' => [ + [ + 'stand_id' => 1, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T10:00:00Z', + 'timeto' => '2026-06-12T11:00:00Z', + ], + [ + 'stand_id' => 1, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T10:30:00Z', + 'timeto' => '2026-06-12T12:00:00Z', + ], + ], + ], + ]; + + $this->makeAuthenticatedApiRequest(self::METHOD_POST, 'stand/reservation-plan', $payload) + ->assertStatus(422) + ->assertJsonValidationErrors(['payload']); + + $this->assertDatabaseMissing( + 'stand_reservation_plans', + [ + 'name' => 'Overlap Test', + 'contact_email' => 'ops@example.org', + ] + ); + } + + public function testItRejectsStandIdentifierWithoutAirportForMultiAirportEvents(): void + { + $payload = [ + 'name' => 'Multi Airport Missing Reservation Airport', + 'contact_email' => 'ops@example.org', + 'payload' => [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'stand' => '251', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ], + ]; + + $this->makeAuthenticatedApiRequest(self::METHOD_POST, 'stand/reservation-plan', $payload) + ->assertStatus(422) + ->assertJsonValidationErrors(['payload']); + + $this->assertDatabaseMissing( + 'stand_reservation_plans', + [ + 'name' => 'Multi Airport Missing Reservation Airport', + 'contact_email' => 'ops@example.org', + ] + ); + } +} From e9c807e3fe52c6913dad2dbfcbe884e9216a57df Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Fri, 17 Apr 2026 15:02:32 +0100 Subject: [PATCH 02/20] Refactor StandReservationPlan payload validation Breaks the large invokable validation into smaller private methods for clarity and testability. Adds validateEventTimes, extractReservations, collectStandIntervals, validateReservationAndBuildInterval and resolveStandKey to parse/validate event times, extract reservations, build per-stand intervals and resolve stand keys. Changes reservations validation to return early when invalid, collects intervalsByStand and then validates overlaps. Preserves existing validation messages while improving structure and null/edge-case handling. --- .../Stand/StandReservationPlanPayload.php | 263 +++++++++++++----- 1 file changed, 195 insertions(+), 68 deletions(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index a13636222..1a59a17a0 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -24,8 +24,41 @@ public function __invoke($attribute, $value, $fail): void return; } - $this->validateUnknownKeys($attribute, $value, ['event_start', 'event_end', 'event_airport', 'event_airports', 'reservations'], $fail); + $this->validateUnknownKeys( + $attribute, + $value, + ['event_start', 'event_end', 'event_airport', 'event_airports', 'reservations'], + $fail + ); + + [$eventStart, $eventEnd] = $this->validateEventTimes($attribute, $value, $fail); + $eventAirports = $this->validateEventAirportScope($attribute, $value, $fail); + $reservations = $this->extractReservations($attribute, $value, $fail); + + if (is_null($reservations)) { + return; + } + + $intervalsByStand = $this->collectStandIntervals( + $attribute, + $reservations, + $eventAirports, + $eventStart, + $eventEnd, + $fail + ); + $this->validateStandIntervalOverlaps($attribute, $intervalsByStand, $fail); + } + + /** + * @param string $attribute + * @param array $value + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array{0:?CarbonImmutable,1:?CarbonImmutable} + */ + private function validateEventTimes(string $attribute, array $value, Closure $fail): array + { $eventStart = $this->parseZuluTime($value['event_start'] ?? null); if (!$eventStart) { $fail("$attribute.event_start must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); @@ -40,102 +73,196 @@ public function __invoke($attribute, $value, $fail): void $fail("$attribute.event_end must be after event_start."); } - $eventAirports = $this->validateEventAirportScope($attribute, $value, $fail); + return [$eventStart, $eventEnd]; + } + /** + * @param string $attribute + * @param array $value + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?array + */ + private function extractReservations(string $attribute, array $value, Closure $fail): ?array + { if (!array_key_exists('reservations', $value) || !is_array($value['reservations']) || count($value['reservations']) === 0) { $fail("$attribute.reservations must be a non-empty array."); - return; + return null; } + return $value['reservations']; + } + + /** + * @param string $attribute + * @param array $reservations + * @param array $eventAirports + * @param ?CarbonImmutable $eventStart + * @param ?CarbonImmutable $eventEnd + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array> + */ + private function collectStandIntervals( + string $attribute, + array $reservations, + array $eventAirports, + ?CarbonImmutable $eventStart, + ?CarbonImmutable $eventEnd, + Closure $fail + ): array { $intervalsByStand = []; - foreach ($value['reservations'] as $index => $reservation) { - $itemPath = "$attribute.reservations.$index"; + foreach ($reservations as $index => $reservation) { + $interval = $this->validateReservationAndBuildInterval( + $attribute, + $index, + $reservation, + $eventAirports, + $eventStart, + $eventEnd, + $fail + ); - if (!is_array($reservation)) { - $fail("$itemPath must be an object."); + if (is_null($interval)) { continue; } - $this->validateUnknownKeys($itemPath, $reservation, ['stand_id', 'stand', 'airport', 'cid', 'timefrom', 'timeto'], $fail); + $intervalsByStand[$interval['stand_key']][] = [ + 'index' => $interval['index'], + 'from' => $interval['from'], + 'to' => $interval['to'], + ]; + } - $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; - $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; + return $intervalsByStand; + } - if ($standIdProvided === $standProvided) { - $fail("$itemPath must include exactly one of stand_id or stand."); - continue; - } + /** + * @param string $attribute + * @param int $index + * @param mixed $reservation + * @param array $eventAirports + * @param ?CarbonImmutable $eventStart + * @param ?CarbonImmutable $eventEnd + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?array{index:int,stand_key:string,from:CarbonImmutable,to:CarbonImmutable} + */ + private function validateReservationAndBuildInterval( + string $attribute, + int $index, + mixed $reservation, + array $eventAirports, + ?CarbonImmutable $eventStart, + ?CarbonImmutable $eventEnd, + Closure $fail + ): ?array { + $itemPath = "$attribute.reservations.$index"; + + if (!is_array($reservation)) { + $fail("$itemPath must be an object."); + return null; + } - if (!$this->isValidCid($reservation['cid'] ?? null)) { - $fail("$itemPath.cid must be a valid VATSIM CID."); - } + $this->validateUnknownKeys($itemPath, $reservation, ['stand_id', 'stand', 'airport', 'cid', 'timefrom', 'timeto'], $fail); - $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); - if (!$timeFrom) { - $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); - } + $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; + $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; - $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); - if (!$timeTo) { - $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); - } + if ($standIdProvided === $standProvided) { + $fail("$itemPath must include exactly one of stand_id or stand."); + return null; + } - if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { - $fail("$itemPath.timeto must be after timefrom."); - } + if (!$this->isValidCid($reservation['cid'] ?? null)) { + $fail("$itemPath.cid must be a valid VATSIM CID."); + } - if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { - $fail("$itemPath.timefrom must be within the event window."); - } + $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); + if (!$timeFrom) { + $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } - if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { - $fail("$itemPath.timeto must be within the event window."); - } + $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); + if (!$timeTo) { + $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } - $standKey = null; - if ($standIdProvided) { - if (!$this->isPositiveInteger($reservation['stand_id'])) { - $fail("$itemPath.stand_id must be a positive integer."); - } else { - $standKey = sprintf('id:%d', $reservation['stand_id']); - } - } + if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { + $fail("$itemPath.timeto must be after timefrom."); + } - if ($standProvided) { - if (!is_string($reservation['stand']) || trim($reservation['stand']) === '') { - $fail("$itemPath.stand must be a non-empty string."); - } + if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { + $fail("$itemPath.timefrom must be within the event window."); + } - $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { + $fail("$itemPath.timeto must be within the event window."); + } - if (is_null($resolvedAirport)) { - if (count($eventAirports) === 1) { - $resolvedAirport = $eventAirports[0]; - } else { - $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); - } - } + $standKey = $this->resolveStandKey($itemPath, $reservation, $standIdProvided, $standProvided, $eventAirports, $fail); - if ($resolvedAirport && is_string($reservation['stand']) && trim($reservation['stand']) !== '') { - $standKey = sprintf( - 'code:%s:%s', - $resolvedAirport, - strtoupper(trim($reservation['stand'])) - ); - } + if (!$standKey || !$timeFrom || !$timeTo) { + return null; + } + + return [ + 'index' => $index, + 'stand_key' => $standKey, + 'from' => $timeFrom, + 'to' => $timeTo, + ]; + } + + /** + * @param string $itemPath + * @param array $reservation + * @param bool $standIdProvided + * @param bool $standProvided + * @param array $eventAirports + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?string + */ + private function resolveStandKey( + string $itemPath, + array $reservation, + bool $standIdProvided, + bool $standProvided, + array $eventAirports, + Closure $fail + ): ?string { + if ($standIdProvided) { + if (!$this->isPositiveInteger($reservation['stand_id'])) { + $fail("$itemPath.stand_id must be a positive integer."); + return null; } - if ($standKey && $timeFrom && $timeTo) { - $intervalsByStand[$standKey][] = [ - 'index' => $index, - 'from' => $timeFrom, - 'to' => $timeTo, - ]; + return sprintf('id:%d', $reservation['stand_id']); + } + + if (!$standProvided) { + return null; + } + + if (!is_string($reservation['stand']) || trim($reservation['stand']) === '') { + $fail("$itemPath.stand must be a non-empty string."); + return null; + } + + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + + if (is_null($resolvedAirport)) { + if (count($eventAirports) === 1) { + $resolvedAirport = $eventAirports[0]; + } else { + $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); + return null; } } - $this->validateStandIntervalOverlaps($attribute, $intervalsByStand, $fail); + return sprintf( + 'code:%s:%s', + $resolvedAirport, + strtoupper(trim($reservation['stand'])) + ); } /** From 9a6766a0b5cad9b98970d170e56b53794dd2e860 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Fri, 17 Apr 2026 15:42:42 +0100 Subject: [PATCH 03/20] Refactor stand reservation validation and parsing Rework validation control flow to avoid early returns and consolidate result construction. Introduces an $interval result that's only returned when stand, from and to are valid; resolveStandKey no longer returns immediately on errors but sets and returns a nullable stand key. event_airport(s) handling was simplified to build an airports array, validate each entry (reporting per-item errors) and check duplicates at the end. Tighten and harden parseZuluTime to more robustly validate and parse Zulu timestamps. Overall this improves error accumulation and readability of the validation logic. --- .../Stand/StandReservationPlanPayload.php | 197 +++++++++--------- 1 file changed, 99 insertions(+), 98 deletions(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 1a59a17a0..b9f52c44d 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -156,60 +156,67 @@ private function validateReservationAndBuildInterval( Closure $fail ): ?array { $itemPath = "$attribute.reservations.$index"; + $interval = null; if (!is_array($reservation)) { $fail("$itemPath must be an object."); - return null; - } + } else { + $this->validateUnknownKeys($itemPath, $reservation, ['stand_id', 'stand', 'airport', 'cid', 'timefrom', 'timeto'], $fail); - $this->validateUnknownKeys($itemPath, $reservation, ['stand_id', 'stand', 'airport', 'cid', 'timefrom', 'timeto'], $fail); + $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; + $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; + $hasSingleStandMode = $standIdProvided !== $standProvided; - $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; - $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; - - if ($standIdProvided === $standProvided) { - $fail("$itemPath must include exactly one of stand_id or stand."); - return null; - } + if (!$hasSingleStandMode) { + $fail("$itemPath must include exactly one of stand_id or stand."); + } - if (!$this->isValidCid($reservation['cid'] ?? null)) { - $fail("$itemPath.cid must be a valid VATSIM CID."); - } + if (!$this->isValidCid($reservation['cid'] ?? null)) { + $fail("$itemPath.cid must be a valid VATSIM CID."); + } - $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); - if (!$timeFrom) { - $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); - } + $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); + if (!$timeFrom) { + $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } - $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); - if (!$timeTo) { - $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); - } + $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); + if (!$timeTo) { + $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } - if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { - $fail("$itemPath.timeto must be after timefrom."); - } + if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { + $fail("$itemPath.timeto must be after timefrom."); + } - if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { - $fail("$itemPath.timefrom must be within the event window."); - } + if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { + $fail("$itemPath.timefrom must be within the event window."); + } - if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { - $fail("$itemPath.timeto must be within the event window."); - } + if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { + $fail("$itemPath.timeto must be within the event window."); + } - $standKey = $this->resolveStandKey($itemPath, $reservation, $standIdProvided, $standProvided, $eventAirports, $fail); + $standKey = $this->resolveStandKey( + $itemPath, + $reservation, + $standIdProvided, + $standProvided, + $eventAirports, + $fail + ); - if (!$standKey || !$timeFrom || !$timeTo) { - return null; + if ($hasSingleStandMode && $standKey && $timeFrom && $timeTo) { + $interval = [ + 'index' => $index, + 'stand_key' => $standKey, + 'from' => $timeFrom, + 'to' => $timeTo, + ]; + } } - return [ - 'index' => $index, - 'stand_key' => $standKey, - 'from' => $timeFrom, - 'to' => $timeTo, - ]; + return $interval; } /** @@ -229,40 +236,39 @@ private function resolveStandKey( array $eventAirports, Closure $fail ): ?string { + $standKey = null; + if ($standIdProvided) { if (!$this->isPositiveInteger($reservation['stand_id'])) { $fail("$itemPath.stand_id must be a positive integer."); - return null; + } else { + $standKey = sprintf('id:%d', $reservation['stand_id']); } + } elseif ($standProvided) { + $stand = $reservation['stand']; - return sprintf('id:%d', $reservation['stand_id']); - } - - if (!$standProvided) { - return null; - } - - if (!is_string($reservation['stand']) || trim($reservation['stand']) === '') { - $fail("$itemPath.stand must be a non-empty string."); - return null; - } + if (!is_string($stand) || trim($stand) === '') { + $fail("$itemPath.stand must be a non-empty string."); + } else { + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); - $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + if (is_null($resolvedAirport) && count($eventAirports) === 1) { + $resolvedAirport = $eventAirports[0]; + } - if (is_null($resolvedAirport)) { - if (count($eventAirports) === 1) { - $resolvedAirport = $eventAirports[0]; - } else { - $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); - return null; + if (is_null($resolvedAirport)) { + $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); + } else { + $standKey = sprintf( + 'code:%s:%s', + $resolvedAirport, + strtoupper(trim($stand)) + ); + } } } - return sprintf( - 'code:%s:%s', - $resolvedAirport, - strtoupper(trim($reservation['stand'])) - ); + return $standKey; } /** @@ -291,41 +297,36 @@ private function validateEventAirportScope(string $attribute, array $payload, Cl { $hasSingleAirport = array_key_exists('event_airport', $payload); $hasMultipleAirports = array_key_exists('event_airports', $payload); + $airports = []; if ($hasSingleAirport === $hasMultipleAirports) { $fail("$attribute must include exactly one of event_airport or event_airports."); - return []; - } - - if ($hasSingleAirport) { + } elseif ($hasSingleAirport) { $airport = $this->normalizeAirportCode($payload['event_airport']); if (!$airport) { $fail("$attribute.event_airport must be a 4-letter ICAO code."); - return []; + } else { + $airports = [$airport]; } + } else { + if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { + $fail("$attribute.event_airports must be a non-empty array of 4-letter ICAO codes."); + } else { + foreach ($payload['event_airports'] as $index => $airport) { + $normalizedAirport = $this->normalizeAirportCode($airport); + if (!$normalizedAirport) { + $fail("$attribute.event_airports.$index must be a 4-letter ICAO code."); + continue; + } + + $airports[] = $normalizedAirport; + } - return [$airport]; - } - - if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { - $fail("$attribute.event_airports must be a non-empty array of 4-letter ICAO codes."); - return []; - } - - $airports = []; - foreach ($payload['event_airports'] as $index => $airport) { - $normalizedAirport = $this->normalizeAirportCode($airport); - if (!$normalizedAirport) { - $fail("$attribute.event_airports.$index must be a 4-letter ICAO code."); - continue; + if (count(array_unique($airports)) !== count($airports)) { + $fail("$attribute.event_airports must not contain duplicate airports."); + } } - - $airports[] = $normalizedAirport; - } - - if (count(array_unique($airports)) !== count($airports)) { - $fail("$attribute.event_airports must not contain duplicate airports."); } return $airports; @@ -348,18 +349,18 @@ private function normalizeAirportCode(mixed $value): ?string private function parseZuluTime(mixed $value): ?CarbonImmutable { - if (!is_string($value) || !preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $value)) { - return null; - } + $parsed = null; - try { - $parsed = CarbonImmutable::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC'); - } catch (\Exception) { - return null; - } + if (is_string($value) && preg_match('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', $value)) { + try { + $candidate = CarbonImmutable::createFromFormat('Y-m-d\TH:i:s\Z', $value, 'UTC'); + } catch (\Exception) { + $candidate = null; + } - if (!$parsed || $parsed->format('Y-m-d\TH:i:s\Z') !== $value) { - return null; + if ($candidate && $candidate->format('Y-m-d\TH:i:s\Z') === $value) { + $parsed = $candidate; + } } return $parsed; From 9669f84d9f367b87287f4b46dde5a2bf48a31394 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Fri, 17 Apr 2026 16:14:11 +0100 Subject: [PATCH 04/20] Refactor reservation validation into helpers Extract validation logic from the main rule into small private helper methods to improve readability and testability. Added validateSingleStandMode, validateReservationTimes, resolveStandKeyFromId, resolveStandKeyFromIdentifier, validateSingleEventAirport and validateMultipleEventAirports. These methods encapsulate existing checks for stand selection, Zulu time parsing and ordering, stand key resolution (by id or code+airport) and event_airport(s) normalization/validation. No validation messages or behaviour were changed; logic was reorganized and returned values made explicit. Co-Authored-By: Copilot <198982749+Copilot@users.noreply.github.com> --- .../Stand/StandReservationPlanPayload.php | 248 ++++++++++++------ 1 file changed, 171 insertions(+), 77 deletions(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index b9f52c44d..05e595285 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -165,37 +165,13 @@ private function validateReservationAndBuildInterval( $standIdProvided = array_key_exists('stand_id', $reservation) && $reservation['stand_id'] !== null; $standProvided = array_key_exists('stand', $reservation) && $reservation['stand'] !== null && $reservation['stand'] !== ''; - $hasSingleStandMode = $standIdProvided !== $standProvided; - - if (!$hasSingleStandMode) { - $fail("$itemPath must include exactly one of stand_id or stand."); - } + $hasSingleStandMode = $this->validateSingleStandMode($itemPath, $standIdProvided, $standProvided, $fail); if (!$this->isValidCid($reservation['cid'] ?? null)) { $fail("$itemPath.cid must be a valid VATSIM CID."); } - $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); - if (!$timeFrom) { - $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); - } - - $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); - if (!$timeTo) { - $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); - } - - if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { - $fail("$itemPath.timeto must be after timefrom."); - } - - if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { - $fail("$itemPath.timefrom must be within the event window."); - } - - if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { - $fail("$itemPath.timeto must be within the event window."); - } + [$timeFrom, $timeTo] = $this->validateReservationTimes($itemPath, $reservation, $eventStart, $eventEnd, $fail); $standKey = $this->resolveStandKey( $itemPath, @@ -219,6 +195,68 @@ private function validateReservationAndBuildInterval( return $interval; } + /** + * @param string $itemPath + * @param bool $standIdProvided + * @param bool $standProvided + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return bool + */ + private function validateSingleStandMode( + string $itemPath, + bool $standIdProvided, + bool $standProvided, + Closure $fail + ): bool { + $hasSingleStandMode = $standIdProvided !== $standProvided; + + if (!$hasSingleStandMode) { + $fail("$itemPath must include exactly one of stand_id or stand."); + } + + return $hasSingleStandMode; + } + + /** + * @param string $itemPath + * @param array $reservation + * @param ?CarbonImmutable $eventStart + * @param ?CarbonImmutable $eventEnd + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array{0:?CarbonImmutable,1:?CarbonImmutable} + */ + private function validateReservationTimes( + string $itemPath, + array $reservation, + ?CarbonImmutable $eventStart, + ?CarbonImmutable $eventEnd, + Closure $fail + ): array { + $timeFrom = $this->parseZuluTime($reservation['timefrom'] ?? null); + if (!$timeFrom) { + $fail("$itemPath.timefrom must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + $timeTo = $this->parseZuluTime($reservation['timeto'] ?? null); + if (!$timeTo) { + $fail("$itemPath.timeto must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); + } + + if ($timeFrom && $timeTo && !$timeTo->isAfter($timeFrom)) { + $fail("$itemPath.timeto must be after timefrom."); + } + + if ($eventStart && $timeFrom && $timeFrom->isBefore($eventStart)) { + $fail("$itemPath.timefrom must be within the event window."); + } + + if ($eventEnd && $timeTo && $timeTo->isAfter($eventEnd)) { + $fail("$itemPath.timeto must be within the event window."); + } + + return [$timeFrom, $timeTo]; + } + /** * @param string $itemPath * @param array $reservation @@ -236,39 +274,68 @@ private function resolveStandKey( array $eventAirports, Closure $fail ): ?string { - $standKey = null; - if ($standIdProvided) { - if (!$this->isPositiveInteger($reservation['stand_id'])) { - $fail("$itemPath.stand_id must be a positive integer."); - } else { - $standKey = sprintf('id:%d', $reservation['stand_id']); - } - } elseif ($standProvided) { - $stand = $reservation['stand']; + return $this->resolveStandKeyFromId($itemPath, $reservation, $fail); + } - if (!is_string($stand) || trim($stand) === '') { - $fail("$itemPath.stand must be a non-empty string."); - } else { - $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + if ($standProvided) { + return $this->resolveStandKeyFromIdentifier($itemPath, $reservation, $eventAirports, $fail); + } - if (is_null($resolvedAirport) && count($eventAirports) === 1) { - $resolvedAirport = $eventAirports[0]; - } + return null; + } - if (is_null($resolvedAirport)) { - $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); - } else { - $standKey = sprintf( - 'code:%s:%s', - $resolvedAirport, - strtoupper(trim($stand)) - ); - } - } + /** + * @param string $itemPath + * @param array $reservation + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?string + */ + private function resolveStandKeyFromId(string $itemPath, array $reservation, Closure $fail): ?string + { + if (!$this->isPositiveInteger($reservation['stand_id'])) { + $fail("$itemPath.stand_id must be a positive integer."); + return null; + } + + return sprintf('id:%d', $reservation['stand_id']); + } + + /** + * @param string $itemPath + * @param array $reservation + * @param array $eventAirports + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return ?string + */ + private function resolveStandKeyFromIdentifier( + string $itemPath, + array $reservation, + array $eventAirports, + Closure $fail + ): ?string { + $stand = $reservation['stand']; + + if (!is_string($stand) || trim($stand) === '') { + $fail("$itemPath.stand must be a non-empty string."); + return null; + } + + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + if (is_null($resolvedAirport) && count($eventAirports) === 1) { + $resolvedAirport = $eventAirports[0]; + } + + if (is_null($resolvedAirport)) { + $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); + return null; } - return $standKey; + return sprintf( + 'code:%s:%s', + $resolvedAirport, + strtoupper(trim($stand)) + ); } /** @@ -297,36 +364,63 @@ private function validateEventAirportScope(string $attribute, array $payload, Cl { $hasSingleAirport = array_key_exists('event_airport', $payload); $hasMultipleAirports = array_key_exists('event_airports', $payload); - $airports = []; if ($hasSingleAirport === $hasMultipleAirports) { $fail("$attribute must include exactly one of event_airport or event_airports."); - } elseif ($hasSingleAirport) { - $airport = $this->normalizeAirportCode($payload['event_airport']); + return []; + } - if (!$airport) { - $fail("$attribute.event_airport must be a 4-letter ICAO code."); - } else { - $airports = [$airport]; - } - } else { - if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { - $fail("$attribute.event_airports must be a non-empty array of 4-letter ICAO codes."); - } else { - foreach ($payload['event_airports'] as $index => $airport) { - $normalizedAirport = $this->normalizeAirportCode($airport); - if (!$normalizedAirport) { - $fail("$attribute.event_airports.$index must be a 4-letter ICAO code."); - continue; - } - - $airports[] = $normalizedAirport; - } + if ($hasSingleAirport) { + return $this->validateSingleEventAirport($attribute, $payload, $fail); + } - if (count(array_unique($airports)) !== count($airports)) { - $fail("$attribute.event_airports must not contain duplicate airports."); - } + return $this->validateMultipleEventAirports($attribute, $payload, $fail); + } + + /** + * @param string $attribute + * @param array $payload + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array + */ + private function validateSingleEventAirport(string $attribute, array $payload, Closure $fail): array + { + $airport = $this->normalizeAirportCode($payload['event_airport']); + + if (!$airport) { + $fail("$attribute.event_airport must be a 4-letter ICAO code."); + return []; + } + + return [$airport]; + } + + /** + * @param string $attribute + * @param array $payload + * @param Closure(string): \Illuminate\Translation\PotentiallyTranslatedString $fail + * @return array + */ + private function validateMultipleEventAirports(string $attribute, array $payload, Closure $fail): array + { + if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { + $fail("$attribute.event_airports must be a non-empty array of 4-letter ICAO codes."); + return []; + } + + $airports = []; + foreach ($payload['event_airports'] as $index => $airport) { + $normalizedAirport = $this->normalizeAirportCode($airport); + if (!$normalizedAirport) { + $fail("$attribute.event_airports.$index must be a 4-letter ICAO code."); + continue; } + + $airports[] = $normalizedAirport; + } + + if (count(array_unique($airports)) !== count($airports)) { + $fail("$attribute.event_airports must not contain duplicate airports."); } return $airports; From 24b979298d0207d3caacfed146bfe02520870416 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Fri, 17 Apr 2026 16:52:34 +0100 Subject: [PATCH 05/20] Remove stand reservation plan feature Delete the StandReservationPlan submission endpoint and related code: removes the controller (app/Http/Controllers/StandReservationPlanController.php), request validation (app/Http/Requests/Stand/StoreStandReservationPlan.php), and tests (tests/app/Http/Controllers/StandReservationPlanControllerTest.php). Also unregisters the POST stand/reservation-plan route in routes/api.php, removing the API for submitting stand reservation plans. --- .../StandReservationPlanController.php | 36 ---- .../Stand/StoreStandReservationPlan.php | 27 --- routes/api.php | 1 - .../StandReservationPlanControllerTest.php | 157 ------------------ 4 files changed, 221 deletions(-) delete mode 100644 app/Http/Controllers/StandReservationPlanController.php delete mode 100644 app/Http/Requests/Stand/StoreStandReservationPlan.php delete mode 100644 tests/app/Http/Controllers/StandReservationPlanControllerTest.php diff --git a/app/Http/Controllers/StandReservationPlanController.php b/app/Http/Controllers/StandReservationPlanController.php deleted file mode 100644 index d6c3f0beb..000000000 --- a/app/Http/Controllers/StandReservationPlanController.php +++ /dev/null @@ -1,36 +0,0 @@ -validated(); - - $plan = StandReservationPlan::create( - [ - 'name' => $validated['name'], - 'contact_email' => $validated['contact_email'], - 'payload' => $validated['payload'], - 'submitted_by' => $request->user()?->id, - 'submitted_at' => Carbon::now(), - 'status' => StandReservationPlanStatus::SUBMITTED, - ] - ); - - return response()->json( - [ - 'id' => $plan->id, - 'status' => $plan->status->value, - ], - 201 - ); - } -} diff --git a/app/Http/Requests/Stand/StoreStandReservationPlan.php b/app/Http/Requests/Stand/StoreStandReservationPlan.php deleted file mode 100644 index 5c363121c..000000000 --- a/app/Http/Requests/Stand/StoreStandReservationPlan.php +++ /dev/null @@ -1,27 +0,0 @@ -user()?->hasRole(RoleKeys::VAA); - } - - /** - * @return array|string> - */ - public function rules(): array - { - return [ - 'name' => ['required', 'string', 'max:255'], - 'contact_email' => ['required', 'email:rfc', 'max:255'], - 'payload' => ['required', 'array', new StandReservationPlanPayload()], - ]; - } -} diff --git a/routes/api.php b/routes/api.php index 8dddc3085..d7df963b5 100644 --- a/routes/api.php +++ b/routes/api.php @@ -93,7 +93,6 @@ function () { Route::post('stand/assignment/requestauto', 'StandController@requestAutomaticStandAssignment'); Route::delete('stand/assignment/{callsign}', 'StandController@deleteStandAssignment') ->where('callsign', VatsimCallsign::CALLSIGN_REGEX); - Route::post('stand/reservation-plan', 'StandReservationPlanController@store'); // Notifications Route::get('notifications', 'NotificationController@getActiveNotifications'); diff --git a/tests/app/Http/Controllers/StandReservationPlanControllerTest.php b/tests/app/Http/Controllers/StandReservationPlanControllerTest.php deleted file mode 100644 index 04f3fefff..000000000 --- a/tests/app/Http/Controllers/StandReservationPlanControllerTest.php +++ /dev/null @@ -1,157 +0,0 @@ -roles()->sync([Role::idFromKey(RoleKeys::VAA)]); - } - - public function testItStoresAValidStandReservationPlan(): void - { - $payload = [ - 'name' => 'Heathrow Summer Event', - 'contact_email' => 'ops@example.org', - 'payload' => [ - 'event_start' => '2026-06-12T08:00:00Z', - 'event_end' => '2026-06-12T20:00:00Z', - 'event_airport' => 'EGLL', - 'reservations' => [ - [ - 'stand_id' => 1, - 'cid' => 1203533, - 'timefrom' => '2026-06-12T08:00:00Z', - 'timeto' => '2026-06-12T10:00:00Z', - ], - [ - 'stand' => '251', - 'cid' => 1203533, - 'timefrom' => '2026-06-12T10:15:00Z', - 'timeto' => '2026-06-12T12:00:00Z', - ], - ], - ], - ]; - - $this->makeAuthenticatedApiRequest(self::METHOD_POST, 'stand/reservation-plan', $payload) - ->assertStatus(201) - ->assertJsonStructure(['id', 'status']) - ->assertJson(['status' => 'submitted']); - - $this->assertDatabaseHas( - 'stand_reservation_plans', - [ - 'name' => 'Heathrow Summer Event', - 'contact_email' => 'ops@example.org', - 'submitted_by' => UserTableSeeder::ACTIVE_USER_CID, - 'status' => 'submitted', - ] - ); - } - - public function testItRejectsNonVaaUser(): void - { - $user = User::findOrFail(UserTableSeeder::ACTIVE_USER_CID); - $user->roles()->sync([]); - - $response = $this->makeAuthenticatedApiRequest( - self::METHOD_POST, - 'stand/reservation-plan', - [ - 'name' => 'Heathrow Summer Event', - 'contact_email' => 'ops@example.org', - 'payload' => [ - 'event_start' => '2026-06-12T08:00:00Z', - 'event_end' => '2026-06-12T20:00:00Z', - 'event_airport' => 'EGLL', - 'reservations' => [], - ], - ] - ); - - $response->assertStatus(403); - } - - public function testItRejectsOverlappingReservationsForTheSameStand(): void - { - $payload = [ - 'name' => 'Overlap Test', - 'contact_email' => 'ops@example.org', - 'payload' => [ - 'event_start' => '2026-06-12T08:00:00Z', - 'event_end' => '2026-06-12T20:00:00Z', - 'event_airport' => 'EGLL', - 'reservations' => [ - [ - 'stand_id' => 1, - 'cid' => 1203533, - 'timefrom' => '2026-06-12T10:00:00Z', - 'timeto' => '2026-06-12T11:00:00Z', - ], - [ - 'stand_id' => 1, - 'cid' => 1203533, - 'timefrom' => '2026-06-12T10:30:00Z', - 'timeto' => '2026-06-12T12:00:00Z', - ], - ], - ], - ]; - - $this->makeAuthenticatedApiRequest(self::METHOD_POST, 'stand/reservation-plan', $payload) - ->assertStatus(422) - ->assertJsonValidationErrors(['payload']); - - $this->assertDatabaseMissing( - 'stand_reservation_plans', - [ - 'name' => 'Overlap Test', - 'contact_email' => 'ops@example.org', - ] - ); - } - - public function testItRejectsStandIdentifierWithoutAirportForMultiAirportEvents(): void - { - $payload = [ - 'name' => 'Multi Airport Missing Reservation Airport', - 'contact_email' => 'ops@example.org', - 'payload' => [ - 'event_start' => '2026-06-12T08:00:00Z', - 'event_end' => '2026-06-12T20:00:00Z', - 'event_airports' => ['EGLL', 'EGKK'], - 'reservations' => [ - [ - 'stand' => '251', - 'cid' => 1203533, - 'timefrom' => '2026-06-12T09:00:00Z', - 'timeto' => '2026-06-12T10:00:00Z', - ], - ], - ], - ]; - - $this->makeAuthenticatedApiRequest(self::METHOD_POST, 'stand/reservation-plan', $payload) - ->assertStatus(422) - ->assertJsonValidationErrors(['payload']); - - $this->assertDatabaseMissing( - 'stand_reservation_plans', - [ - 'name' => 'Multi Airport Missing Reservation Airport', - 'contact_email' => 'ops@example.org', - ] - ); - } -} From 12ab0fe39c9898757e2d75c16ffad4b1870d73d6 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 18 Apr 2026 05:50:38 +0100 Subject: [PATCH 06/20] Update docs/guides/VaaStandReservationPlans.md Co-authored-by: Coby Chapman --- docs/guides/VaaStandReservationPlans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md index fc72f0f16..bfbe8d830 100644 --- a/docs/guides/VaaStandReservationPlans.md +++ b/docs/guides/VaaStandReservationPlans.md @@ -96,7 +96,7 @@ The server applies these rules: } ``` -## Invalid Example (Overlapping Same Stand) +## Invalid Example ```json { From cdeb0506d2103766dda7a8207760ed8f64d4cc9e Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 18 Apr 2026 05:50:45 +0100 Subject: [PATCH 07/20] Update app/Rules/Stand/StandReservationPlanPayload.php Co-authored-by: Coby Chapman --- app/Rules/Stand/StandReservationPlanPayload.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 05e595285..60258e6ba 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -69,7 +69,7 @@ private function validateEventTimes(string $attribute, array $value, Closure $fa $fail("$attribute.event_end must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); } - if ($eventStart && $eventEnd && !$eventEnd->isAfter($eventStart)) { + if (!$eventEnd->isAfter($eventStart)) { $fail("$attribute.event_end must be after event_start."); } From e3b84be12f8879614f34a887b7ffd3266fc65add Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 18 Apr 2026 05:50:53 +0100 Subject: [PATCH 08/20] Update app/Rules/Stand/StandReservationPlanPayload.php Co-authored-by: Coby Chapman --- app/Rules/Stand/StandReservationPlanPayload.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 60258e6ba..89350a59b 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -293,7 +293,7 @@ private function resolveStandKey( */ private function resolveStandKeyFromId(string $itemPath, array $reservation, Closure $fail): ?string { - if (!$this->isPositiveInteger($reservation['stand_id'])) { + if (!$reservation['stand_id'] > 0) { $fail("$itemPath.stand_id must be a positive integer."); return null; } From 8a5f704e20ba1b1d9794ac2fc84222cbe6412d55 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 18 Apr 2026 05:51:01 +0100 Subject: [PATCH 09/20] Update docs/guides/StandAllocation.md Co-authored-by: Coby Chapman --- docs/guides/StandAllocation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/StandAllocation.md b/docs/guides/StandAllocation.md index 79856cd53..f772dfe77 100644 --- a/docs/guides/StandAllocation.md +++ b/docs/guides/StandAllocation.md @@ -5,7 +5,7 @@ Whilst the final decision on where aircraft should park belongs to the controlle a realistic stand is assigned to each flight, based on a number of parameters. This is a highly complex system, so this guide is intended to explain how it all works under the hood. -## Related guide +## VAA Stand Reservation System For VAA event stand reservation plan payloads and JSON validation requirements, see `docs/guides/VaaStandReservationPlans.md`. From 90aa772de512622f93a89bf8c5c8c3f4de5f1d96 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 18 Apr 2026 05:51:12 +0100 Subject: [PATCH 10/20] Update docs/guides/VaaStandReservationPlans.md Co-authored-by: Coby Chapman --- docs/guides/VaaStandReservationPlans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md index bfbe8d830..2ccc9f7d7 100644 --- a/docs/guides/VaaStandReservationPlans.md +++ b/docs/guides/VaaStandReservationPlans.md @@ -54,7 +54,7 @@ The server applies these rules: - The same stand can be reused at different times if time windows do not overlap. - Overlapping reservations for the same stand are rejected. -## Valid Example (Multi-Stand, Reused Stand) +## Valid Example ```json { From 974832a60ee926196f368e852d78c224fb06f78e Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 18 Apr 2026 07:52:43 +0100 Subject: [PATCH 11/20] Restrict ICAO to UK, tighten stand_id, add tests Tighten validation for stand reservation payloads: require stand_id to be a positive integer, restrict airport ICAO codes to UK prefixes (EG|EI) and update related error messages. Remove an unused isPositiveInteger helper and adjust airport normalization/validation logic. Update docs to clarify single vs multi-airport wording, event window requirements, and reservation rules. Add comprehensive unit tests covering valid/invalid UK ICAOs, overlapping reservations, out-of-window times, unknown fields, and negative stand IDs. --- .../Stand/StandReservationPlanPayload.php | 15 +- docs/guides/VaaStandReservationPlans.md | 24 ++- .../Stand/StandReservationPlanPayloadTest.php | 140 ++++++++++++++++++ 3 files changed, 154 insertions(+), 25 deletions(-) create mode 100644 tests/app/Rules/Stand/StandReservationPlanPayloadTest.php diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 89350a59b..31be3531b 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -293,7 +293,7 @@ private function resolveStandKey( */ private function resolveStandKeyFromId(string $itemPath, array $reservation, Closure $fail): ?string { - if (!$reservation['stand_id'] > 0) { + if (!is_int($reservation['stand_id']) || $reservation['stand_id'] <= 0) { $fail("$itemPath.stand_id must be a positive integer."); return null; } @@ -388,7 +388,7 @@ private function validateSingleEventAirport(string $attribute, array $payload, C $airport = $this->normalizeAirportCode($payload['event_airport']); if (!$airport) { - $fail("$attribute.event_airport must be a 4-letter ICAO code."); + $fail("$attribute.event_airport must be a UK 4-letter ICAO code."); return []; } @@ -404,7 +404,7 @@ private function validateSingleEventAirport(string $attribute, array $payload, C private function validateMultipleEventAirports(string $attribute, array $payload, Closure $fail): array { if (!is_array($payload['event_airports']) || count($payload['event_airports']) === 0) { - $fail("$attribute.event_airports must be a non-empty array of 4-letter ICAO codes."); + $fail("$attribute.event_airports must be a non-empty array of UK 4-letter ICAO codes."); return []; } @@ -412,7 +412,7 @@ private function validateMultipleEventAirports(string $attribute, array $payload foreach ($payload['event_airports'] as $index => $airport) { $normalizedAirport = $this->normalizeAirportCode($airport); if (!$normalizedAirport) { - $fail("$attribute.event_airports.$index must be a 4-letter ICAO code."); + $fail("$attribute.event_airports.$index must be a UK 4-letter ICAO code."); continue; } @@ -434,7 +434,7 @@ private function normalizeAirportCode(mixed $value): ?string $airport = strtoupper(trim($value)); - if (!preg_match('/^[A-Z]{4}$/', $airport)) { + if (!preg_match('/^(EG|EI)[A-Z]{2}$/', $airport)) { return null; } @@ -460,11 +460,6 @@ private function parseZuluTime(mixed $value): ?CarbonImmutable return $parsed; } - private function isPositiveInteger(mixed $value): bool - { - return is_int($value) && $value > 0; - } - private function isValidCid(mixed $value): bool { return is_int($value) && VatsimCidValidator::isValid($value); diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md index 2ccc9f7d7..ef7956b36 100644 --- a/docs/guides/VaaStandReservationPlans.md +++ b/docs/guides/VaaStandReservationPlans.md @@ -19,19 +19,23 @@ A stand reservation plan submission contains: - `event_start` (string, required): Event start in Zulu. - `event_end` (string, required): Event end in Zulu and after `event_start`. -- Exactly one of: + +Use exactly one of the following: + - `event_airport` (string, required if single-airport event): 4-letter ICAO. - `event_airports` (array of strings, required if multi-airport event): non-empty, unique 4-letter ICAOs. -- `reservations` (array, required): One or more reservation objects. +- `reservations` (array, required): One or more reservation objects. Multiple stands can be included in one plan, and the same stand can be reused at different times as long as the time windows do not overlap. ## Reservation Schema Each item in `payload.reservations` must include: - `cid` (integer, required): Valid VATSIM CID. -- `timefrom` (string, required): Reservation start in Zulu. -- `timeto` (string, required): Reservation end in Zulu and after `timefrom`. -- Exactly one stand reference mode: +- `timefrom` (string, required): Reservation start in Zulu. Must be inside the event window (`event_start` to `event_end`). +- `timeto` (string, required): Reservation end in Zulu and after `timefrom`. Must be inside the event window (`event_start` to `event_end`). + +Use exactly one stand reference mode: + - `stand_id` (integer, required if using stand ID mode) - `stand` (string, required if using stand identifier mode) @@ -44,16 +48,6 @@ Optional field: - If you use `stand` and the event has one airport (`event_airport`), `airport` may be omitted. - If you use `stand` and the event has multiple airports (`event_airports`), `airport` is required per reservation. -## Validation Rules - -The server applies these rules: - -- Unknown fields are rejected (strict schema). -- Reservation times must be inside the event window (`event_start` to `event_end`). -- Multiple stands can be included in one plan. -- The same stand can be reused at different times if time windows do not overlap. -- Overlapping reservations for the same stand are rejected. - ## Valid Example ```json diff --git a/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php new file mode 100644 index 000000000..14dc9152e --- /dev/null +++ b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php @@ -0,0 +1,140 @@ +rule = new StandReservationPlanPayload(); + } + + private function validatePayload(array $payload): bool + { + return !Validator::make( + ['payload' => $payload], + ['payload' => $this->rule] + )->fails(); + } + + private function validSingleAirportPayload(): array + { + return [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EGLL', + 'reservations' => [ + [ + 'stand_id' => 1201, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + } + + public function testItAcceptsAValidStandIdPlan(): void + { + $this->assertTrue($this->validatePayload($this->validSingleAirportPayload())); + } + + public function testItAcceptsAValidStandIdentifierPlan(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'airport' => 'EGLL', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $this->assertTrue($this->validatePayload($payload)); + } + + public function testItAcceptsEiPrefixedUkIcaoCodes(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airport' => 'EIDW', + 'reservations' => [ + [ + 'airport' => 'EIDW', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $this->assertTrue($this->validatePayload($payload)); + } + + public function testItRejectsNonUkIcaoCodes(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['event_airport'] = 'LFPG'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsUnknownFields(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['unexpected'] = 'value'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsReservationsOutsideTheEventWindow(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['timefrom'] = '2026-06-12T07:59:59Z'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsOverlappingReservationsForTheSameStand(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'] = [ + [ + 'stand_id' => 1201, + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + [ + 'stand_id' => 1201, + 'cid' => 1203534, + 'timefrom' => '2026-06-12T09:30:00Z', + 'timeto' => '2026-06-12T10:30:00Z', + ], + ]; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsNegativeStandIds(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['stand_id'] = -1; + + $this->assertFalse($this->validatePayload($payload)); + } +} From 890b82e75e06ec098d32383294cbda06bc419973 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sun, 19 Apr 2026 06:40:50 +0100 Subject: [PATCH 12/20] Add VAA stand reservation schema and docs Add a JSON Schema for VAA stand reservation plans at docs/guides/schemas/vaa-stand-reservation-plan.schema.json and update the guide to reference it. The schema validates payload structure, field types, ISO 8601 Zulu timestamps, CID ranges, UK ICAO formats, and stand reference modes. The doc clarifies time formatting and lists server-side checks that JSON Schema cannot enforce (event_end > event_start, reservation timeto > timefrom, reservations within the event window, and no overlapping reservations for the same stand). --- docs/guides/VaaStandReservationPlans.md | 17 +- .../vaa-stand-reservation-plan.schema.json | 187 ++++++++++++++++++ 2 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 docs/guides/schemas/vaa-stand-reservation-plan.schema.json diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md index ef7956b36..3d40cd046 100644 --- a/docs/guides/VaaStandReservationPlans.md +++ b/docs/guides/VaaStandReservationPlans.md @@ -2,11 +2,26 @@ This guide explains how Virtual Airline Administrators (VAAs) should build JSON payloads for stand reservation plans. -All times must be in Zulu (UTC) format: +All times must be in ISO 8601 Zulu (UTC) format: - `YYYY-MM-DDTHH:MM:SSZ` - Example: `2026-06-12T14:30:00Z` +## JSON Schema + +Use this schema for pre-validation in editors, CI, or upload tooling: + +- `docs/guides/schemas/vaa-stand-reservation-plan.schema.json` + +The schema validates structure, field types, allowed keys, timestamp shape, CID ranges, UK ICAO format, and stand reference mode. + +Server-side checks are still required for rules that JSON Schema cannot enforce on its own: + +- `event_end` must be after `event_start`. +- Reservation `timeto` must be after `timefrom`. +- Reservation times must stay within the event window. +- The same stand must not have overlapping reservation windows. + ## Top-Level Schema A stand reservation plan submission contains: diff --git a/docs/guides/schemas/vaa-stand-reservation-plan.schema.json b/docs/guides/schemas/vaa-stand-reservation-plan.schema.json new file mode 100644 index 000000000..0570b5733 --- /dev/null +++ b/docs/guides/schemas/vaa-stand-reservation-plan.schema.json @@ -0,0 +1,187 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://vatsim.uk/schemas/vaa-stand-reservation-plan.schema.json", + "title": "VAA Stand Reservation Plan", + "description": "Schema for VAA stand reservation plan submissions.", + "type": "object", + "additionalProperties": false, + "required": [ + "name", + "contact_email", + "payload" + ], + "properties": { + "name": { + "type": "string", + "minLength": 1 + }, + "contact_email": { + "type": "string", + "format": "email" + }, + "payload": { + "type": "object", + "additionalProperties": false, + "required": [ + "event_start", + "event_end", + "reservations" + ], + "properties": { + "event_start": { + "$ref": "#/$defs/zuluTimestamp" + }, + "event_end": { + "$ref": "#/$defs/zuluTimestamp" + }, + "event_airport": { + "$ref": "#/$defs/ukIcao" + }, + "event_airports": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/ukIcao" + } + }, + "reservations": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/reservation" + } + } + }, + "oneOf": [ + { + "required": [ + "event_airport" + ], + "not": { + "required": [ + "event_airports" + ] + } + }, + { + "required": [ + "event_airports" + ], + "not": { + "required": [ + "event_airport" + ] + } + } + ], + "allOf": [ + { + "if": { + "required": [ + "event_airports" + ] + }, + "then": { + "properties": { + "reservations": { + "type": "array", + "items": { + "allOf": [ + { + "if": { + "required": [ + "stand" + ] + }, + "then": { + "required": [ + "airport" + ] + } + } + ] + } + } + } + } + } + ] + } + }, + "$defs": { + "zuluTimestamp": { + "type": "string", + "pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$" + }, + "ukIcao": { + "type": "string", + "pattern": "^(EG|EI)[A-Z]{2}$" + }, + "cid": { + "type": "integer", + "anyOf": [ + { + "minimum": 810000 + }, + { + "minimum": 800000, + "maximum": 800150 + } + ] + }, + "reservation": { + "type": "object", + "additionalProperties": false, + "required": [ + "cid", + "timefrom", + "timeto" + ], + "properties": { + "stand_id": { + "type": "integer", + "minimum": 1 + }, + "stand": { + "type": "string", + "minLength": 1 + }, + "airport": { + "$ref": "#/$defs/ukIcao" + }, + "cid": { + "$ref": "#/$defs/cid" + }, + "timefrom": { + "$ref": "#/$defs/zuluTimestamp" + }, + "timeto": { + "$ref": "#/$defs/zuluTimestamp" + } + }, + "oneOf": [ + { + "required": [ + "stand_id" + ], + "not": { + "required": [ + "stand" + ] + } + }, + { + "required": [ + "stand" + ], + "not": { + "required": [ + "stand_id" + ] + } + } + ] + } + } +} \ No newline at end of file From e9654fd213461863dc56e42bea91aa88a3b8a3e9 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Mon, 4 May 2026 19:26:56 +0100 Subject: [PATCH 13/20] Update app/Rules/Stand/StandReservationPlanPayload.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- app/Rules/Stand/StandReservationPlanPayload.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 31be3531b..9b2867a05 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -69,7 +69,7 @@ private function validateEventTimes(string $attribute, array $value, Closure $fa $fail("$attribute.event_end must be a Zulu timestamp in format YYYY-MM-DDTHH:MM:SSZ."); } - if (!$eventEnd->isAfter($eventStart)) { + if ($eventStart && $eventEnd && !$eventEnd->isAfter($eventStart)) { $fail("$attribute.event_end must be after event_start."); } From a80ebdeb091e1e7aa35f0d033bcbebb3d291b495 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Mon, 4 May 2026 19:27:15 +0100 Subject: [PATCH 14/20] Update docs/guides/VaaStandReservationPlans.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- docs/guides/VaaStandReservationPlans.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md index 3d40cd046..7a7b88ac8 100644 --- a/docs/guides/VaaStandReservationPlans.md +++ b/docs/guides/VaaStandReservationPlans.md @@ -34,12 +34,12 @@ A stand reservation plan submission contains: - `event_start` (string, required): Event start in Zulu. - `event_end` (string, required): Event end in Zulu and after `event_start`. +- `reservations` (array, required): One or more reservation objects. Multiple stands can be included in one plan, and the same stand can be reused at different times as long as the time windows do not overlap. -Use exactly one of the following: +Use exactly one of the following airport scope fields: - `event_airport` (string, required if single-airport event): 4-letter ICAO. - `event_airports` (array of strings, required if multi-airport event): non-empty, unique 4-letter ICAOs. -- `reservations` (array, required): One or more reservation objects. Multiple stands can be included in one plan, and the same stand can be reused at different times as long as the time windows do not overlap. ## Reservation Schema From 334360dade5406de554af88ecbdf4cbb24b213d9 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Mon, 4 May 2026 19:27:43 +0100 Subject: [PATCH 15/20] Update tests/app/Rules/Stand/StandReservationPlanPayloadTest.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../Stand/StandReservationPlanPayloadTest.php | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php index 14dc9152e..83b22a193 100644 --- a/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php +++ b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php @@ -45,6 +45,37 @@ public function testItAcceptsAValidStandIdPlan(): void $this->assertTrue($this->validatePayload($this->validSingleAirportPayload())); } + public function testItRejectsAPlanWhereEventEndIsNotAfterEventStart(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['event_end'] = $payload['event_start']; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsAReservationWhereTimeToIsNotAfterTimeFrom(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['timeto'] = $payload['reservations'][0]['timefrom']; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsAReservationWhenBothStandIdAndStandAreProvided(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'][0]['stand'] = 'A23'; + + $this->assertFalse($this->validatePayload($payload)); + } + + public function testItRejectsAReservationWhenNeitherStandIdNorStandIsProvided(): void + { + $payload = $this->validSingleAirportPayload(); + unset($payload['reservations'][0]['stand_id']); + + $this->assertFalse($this->validatePayload($payload)); + } public function testItAcceptsAValidStandIdentifierPlan(): void { $payload = [ From 148a09e7f64423a61d0291d363a5021999f24fbf Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Mon, 4 May 2026 19:29:29 +0100 Subject: [PATCH 16/20] Validate airport is one of event airports Add a guard to StandReservationPlanPayload to ensure the resolved airport exists in the event's airports. If the airport is not found, the rule triggers a failure with "$itemPath.airport must be one of the event's airports." and returns null. Uses a strict in_array check to prevent invalid airport values. --- app/Rules/Stand/StandReservationPlanPayload.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 9b2867a05..2fa4be56b 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -331,6 +331,11 @@ private function resolveStandKeyFromIdentifier( return null; } + if (!in_array($resolvedAirport, $eventAirports, true)) { + $fail("$itemPath.airport must be one of the event's airports."); + return null; + } + return sprintf( 'code:%s:%s', $resolvedAirport, From 9f5a00069ba38d686ce032e77398cb46d5020a8b Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Mon, 4 May 2026 19:31:21 +0100 Subject: [PATCH 17/20] Combine airport null and membership checks Consolidate two separate validations into a single conditional: if the resolved airport is null or not in the event's airports, fail with an appropriate message chosen via a conditional expression. This reduces duplicated fail/return branches and clarifies the validation logic in StandReservationPlanPayload. --- app/Rules/Stand/StandReservationPlanPayload.php | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 2fa4be56b..0a0546a34 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -326,13 +326,12 @@ private function resolveStandKeyFromIdentifier( $resolvedAirport = $eventAirports[0]; } - if (is_null($resolvedAirport)) { - $fail("$itemPath.airport is required when event_airports contains multiple airports and stand is used."); - return null; - } - - if (!in_array($resolvedAirport, $eventAirports, true)) { - $fail("$itemPath.airport must be one of the event's airports."); + if (is_null($resolvedAirport) || !in_array($resolvedAirport, $eventAirports, true)) { + $fail( + is_null($resolvedAirport) + ? "$itemPath.airport is required when event_airports contains multiple airports and stand is used." + : "$itemPath.airport must be one of the event's airports." + ); return null; } From 87f9b6b9b09a19440ad6414395fbdc97939ffd43 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Mon, 4 May 2026 19:36:07 +0100 Subject: [PATCH 18/20] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- docs/guides/VaaStandReservationPlans.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guides/VaaStandReservationPlans.md b/docs/guides/VaaStandReservationPlans.md index 7a7b88ac8..f53c3cd4d 100644 --- a/docs/guides/VaaStandReservationPlans.md +++ b/docs/guides/VaaStandReservationPlans.md @@ -13,7 +13,7 @@ Use this schema for pre-validation in editors, CI, or upload tooling: - `docs/guides/schemas/vaa-stand-reservation-plan.schema.json` -The schema validates structure, field types, allowed keys, timestamp shape, CID ranges, UK ICAO format, and stand reference mode. +The schema validates structure, field types, allowed keys, timestamp shape, CID ranges, EG/EI ICAO format, and stand reference mode. Server-side checks are still required for rules that JSON Schema cannot enforce on its own: From ca53e506690b2cb851488636fc485d9b1e6fd7a2 Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 16 May 2026 20:43:10 +0100 Subject: [PATCH 19/20] Validate reservations as list Require reservations to be a non-empty list (using array_is_list) and update the validation message accordingly. Add an early return when event_airports is an empty array to avoid emitting reservation airport mismatch errors for invalid event_airports. Include tests for missing airport on multi-airport events, airport not in event_airports, ignoring airport-mismatch when event_airports is invalid/empty, and rejecting reservations that are not a list. --- .../Stand/StandReservationPlanPayload.php | 13 ++- .../Stand/StandReservationPlanPayloadTest.php | 101 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index 0a0546a34..de5484b72 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -84,8 +84,13 @@ private function validateEventTimes(string $attribute, array $value, Closure $fa */ private function extractReservations(string $attribute, array $value, Closure $fail): ?array { - if (!array_key_exists('reservations', $value) || !is_array($value['reservations']) || count($value['reservations']) === 0) { - $fail("$attribute.reservations must be a non-empty array."); + if ( + !array_key_exists('reservations', $value) + || !is_array($value['reservations']) + || count($value['reservations']) === 0 + || !array_is_list($value['reservations']) + ) { + $fail("$attribute.reservations must be a non-empty list of reservations."); return null; } @@ -321,6 +326,10 @@ private function resolveStandKeyFromIdentifier( return null; } + if ($eventAirports === []) { + return null; + } + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); if (is_null($resolvedAirport) && count($eventAirports) === 1) { $resolvedAirport = $eventAirports[0]; diff --git a/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php index 83b22a193..8f5ec131a 100644 --- a/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php +++ b/tests/app/Rules/Stand/StandReservationPlanPayloadTest.php @@ -96,6 +96,57 @@ public function testItAcceptsAValidStandIdentifierPlan(): void $this->assertTrue($this->validatePayload($payload)); } + public function testItRejectsAStandIdentifierPlanWhenAirportIsMissingForMultiAirportEvents(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + "payload.reservations.0.airport is required when event_airports contains multiple airports and stand is used.", + $validator->errors()->all() + ); + } + + public function testItRejectsAStandIdentifierPlanWhenAirportIsNotOneOfTheEventAirports(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => ['EGLL', 'EGKK'], + 'reservations' => [ + [ + 'airport' => 'EGCC', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + "payload.reservations.0.airport must be one of the event's airports.", + $validator->errors()->all() + ); + } + public function testItAcceptsEiPrefixedUkIcaoCodes(): void { $payload = [ @@ -116,6 +167,56 @@ public function testItAcceptsEiPrefixedUkIcaoCodes(): void $this->assertTrue($this->validatePayload($payload)); } + public function testItDoesNotEmitAStandAirportMismatchWhenEventAirportsAreInvalid(): void + { + $payload = [ + 'event_start' => '2026-06-12T08:00:00Z', + 'event_end' => '2026-06-12T20:00:00Z', + 'event_airports' => [], + 'reservations' => [ + [ + 'airport' => 'EGLL', + 'stand' => 'A23', + 'cid' => 1203533, + 'timefrom' => '2026-06-12T09:00:00Z', + 'timeto' => '2026-06-12T10:00:00Z', + ], + ], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + 'payload.event_airports must be a non-empty array of UK 4-letter ICAO codes.', + $validator->errors()->all() + ); + $this->assertNotContains( + "payload.reservations.0.airport is required when event_airports contains multiple airports and stand is used.", + $validator->errors()->all() + ); + $this->assertNotContains( + "payload.reservations.0.airport must be one of the event's airports.", + $validator->errors()->all() + ); + } + + public function testItRejectsReservationsThatAreNotAList(): void + { + $payload = $this->validSingleAirportPayload(); + $payload['reservations'] = [ + 'stand-one' => $payload['reservations'][0], + ]; + + $validator = Validator::make(['payload' => $payload], ['payload' => $this->rule]); + + $this->assertFalse($validator->passes()); + $this->assertContains( + 'payload.reservations must be a non-empty list of reservations.', + $validator->errors()->all() + ); + } + public function testItRejectsNonUkIcaoCodes(): void { $payload = $this->validSingleAirportPayload(); From 81d526ccedc1c4d1635a161de842e4df54ebbeda Mon Sep 17 00:00:00 2001 From: Daniel Green Date: Sat, 16 May 2026 20:46:24 +0100 Subject: [PATCH 20/20] Refactor airport resolution in payload rule Rework conditional flow for airport resolution/validation in StandReservationPlanPayload: combine the event airports check with the airport normalization/validation block, move the successful return inside that block, and unify the final return to null. Preserves existing failure behavior (calls $fail when airport is missing or not one of the event's airports) while simplifying control flow and reducing early returns. --- .../Stand/StandReservationPlanPayload.php | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/app/Rules/Stand/StandReservationPlanPayload.php b/app/Rules/Stand/StandReservationPlanPayload.php index de5484b72..4314c6a94 100644 --- a/app/Rules/Stand/StandReservationPlanPayload.php +++ b/app/Rules/Stand/StandReservationPlanPayload.php @@ -326,29 +326,28 @@ private function resolveStandKeyFromIdentifier( return null; } - if ($eventAirports === []) { - return null; - } - - $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); - if (is_null($resolvedAirport) && count($eventAirports) === 1) { - $resolvedAirport = $eventAirports[0]; - } + if ($eventAirports !== []) { + $resolvedAirport = $this->normalizeAirportCode($reservation['airport'] ?? null); + if (is_null($resolvedAirport) && count($eventAirports) === 1) { + $resolvedAirport = $eventAirports[0]; + } - if (is_null($resolvedAirport) || !in_array($resolvedAirport, $eventAirports, true)) { - $fail( - is_null($resolvedAirport) - ? "$itemPath.airport is required when event_airports contains multiple airports and stand is used." - : "$itemPath.airport must be one of the event's airports." - ); - return null; + if (is_null($resolvedAirport) || !in_array($resolvedAirport, $eventAirports, true)) { + $fail( + is_null($resolvedAirport) + ? "$itemPath.airport is required when event_airports contains multiple airports and stand is used." + : "$itemPath.airport must be one of the event's airports." + ); + } else { + return sprintf( + 'code:%s:%s', + $resolvedAirport, + strtoupper(trim($stand)) + ); + } } - return sprintf( - 'code:%s:%s', - $resolvedAirport, - strtoupper(trim($stand)) - ); + return null; } /**