-
Notifications
You must be signed in to change notification settings - Fork 80
Sync bookings to .net bookings calendar #5160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Jobs\Booking; | ||
|
|
||
| use App\Jobs\Concerns\LogsJobFailure; | ||
| use App\Jobs\Job; | ||
| use App\Models\Booking; | ||
| use App\Services\Bookings\VatsimNetBookingSyncService; | ||
| use Illuminate\Contracts\Queue\ShouldQueue; | ||
| use Illuminate\Foundation\Bus\Dispatchable; | ||
| use Illuminate\Queue\InteractsWithQueue; | ||
| use Illuminate\Queue\SerializesModels; | ||
|
|
||
| class SyncToVatsimNet extends Job implements ShouldQueue | ||
| { | ||
| use Dispatchable, InteractsWithQueue, LogsJobFailure, SerializesModels; | ||
|
|
||
| public function __construct( | ||
| private readonly int $bookingId, | ||
| private readonly bool $deleted = false, | ||
| private readonly ?int $remoteId = null, | ||
| ) {} | ||
|
|
||
| public function handle(VatsimNetBookingSyncService $service): void | ||
| { | ||
| if ($this->deleted) { | ||
| $service->delete($this->remoteId); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| $booking = Booking::find($this->bookingId); | ||
|
|
||
| if ($booking === null) { | ||
| return; | ||
| } | ||
|
|
||
| $service->sync($booking); | ||
| } | ||
|
|
||
| protected function logJobContext(): array | ||
| { | ||
| return [ | ||
| 'booking_id' => $this->bookingId, | ||
| 'deleted' => $this->deleted, | ||
| ]; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Libraries; | ||
|
|
||
| use Illuminate\Support\Facades\Http; | ||
| use Illuminate\Support\Facades\Log; | ||
|
|
||
| class VatsimNetBookings | ||
| { | ||
| private string $url; | ||
|
|
||
| private string $key; | ||
|
|
||
| public function __construct() | ||
| { | ||
| $this->url = (string) config('services.vatsim-net.bookings.url'); | ||
| $this->key = (string) config('services.vatsim-net.bookings.key'); | ||
| } | ||
|
|
||
| public function create(array $payload): int | ||
| { | ||
| $response = $this->client()->post('booking', $payload); | ||
|
|
||
| $this->logFailure('create', $response->status(), $response->body()); | ||
|
|
||
| $response->throw(); | ||
|
|
||
| $id = (int) $response->json('id'); | ||
|
|
||
| if ($id <= 0) { | ||
| throw new \RuntimeException('VATSIM.net booking create returned an invalid id.'); | ||
| } | ||
|
|
||
| return $id; | ||
| } | ||
|
|
||
| public function update(int $remoteId, array $payload): void | ||
| { | ||
| $response = $this->client()->put("booking/{$remoteId}", $payload); | ||
|
|
||
| $this->logFailure('update', $response->status(), $response->body()); | ||
|
|
||
| $response->throw(); | ||
| } | ||
|
|
||
| public function delete(int $remoteId): void | ||
| { | ||
| $response = $this->client()->delete("booking/{$remoteId}"); | ||
|
|
||
| $this->logFailure('delete', $response->status(), $response->body()); | ||
|
|
||
| $response->throw(); | ||
| } | ||
|
|
||
| private function client() | ||
| { | ||
| return Http::baseUrl($this->url) | ||
| ->withToken($this->key) | ||
| ->acceptJson() | ||
| ->asJson(); | ||
| } | ||
|
|
||
| private function logFailure(string $method, int $status, string $body): void | ||
| { | ||
| if ($status >= 400) { | ||
| Log::warning('VATSIM.net booking request failed', [ | ||
| 'method' => $method, | ||
| 'status' => $status, | ||
| 'body' => $body, | ||
| ]); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Observers; | ||
|
|
||
| use App\Jobs\Booking\SyncToVatsimNet; | ||
| use App\Models\Booking; | ||
|
|
||
| class BookingObserver | ||
| { | ||
| private const RELEVANT_FIELDS = [ | ||
|
Comment on lines
+10
to
+12
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [bug · high]
Other observers in this project (e.g. |
||
| 'position_id', | ||
| 'member_id', | ||
| 'starts_at', | ||
| 'ends_at', | ||
| 'type', | ||
| 'cts_booking_id', | ||
| ]; | ||
|
|
||
| public function created(Booking $booking): void | ||
| { | ||
| $this->dispatch($booking); | ||
| } | ||
|
|
||
| public function updated(Booking $booking): void | ||
| { | ||
| if ($booking->wasChanged(self::RELEVANT_FIELDS)) { | ||
| $this->dispatch($booking); | ||
| } | ||
| } | ||
|
|
||
| public function deleted(Booking $booking): void | ||
| { | ||
| $this->dispatch($booking, deleted: true); | ||
| } | ||
|
|
||
| private function dispatch(Booking $booking, bool $deleted = false): void | ||
| { | ||
| if ($this->shouldSkip($booking)) { | ||
| return; | ||
| } | ||
|
|
||
| $remoteId = $booking->vatsim_net_booking_id !== null ? (int) $booking->vatsim_net_booking_id : null; | ||
|
|
||
| SyncToVatsimNet::dispatch($booking->getKey(), $deleted, $remoteId); | ||
| } | ||
|
|
||
| private function shouldSkip(Booking $booking): bool | ||
| { | ||
| if ((string) config('services.vatsim-net.bookings.key') === '') { | ||
| return true; | ||
| } | ||
|
|
||
| return $booking->type === Booking::TYPE_EVENT; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Services\Bookings; | ||
|
|
||
| use App\Libraries\VatsimNetBookings; | ||
| use App\Models\Booking; | ||
| use App\Models\Cts\ExamBooking; | ||
| use App\Models\Cts\Session; | ||
|
|
||
| class VatsimNetBookingSyncService | ||
| { | ||
| public function __construct( | ||
| private readonly VatsimNetBookings $bookings, | ||
| ) {} | ||
|
|
||
| public function sync(Booking $booking): void | ||
| { | ||
| $payload = $this->payload($booking); | ||
|
|
||
| if ($payload === null) { | ||
| return; | ||
| } | ||
|
|
||
| if ($booking->vatsim_net_booking_id !== null) { | ||
| $this->bookings->update((int) $booking->vatsim_net_booking_id, $payload); | ||
|
|
||
| return; | ||
| } | ||
|
|
||
| $remoteId = $this->bookings->create($payload); | ||
|
|
||
| $booking->updateQuietly(['vatsim_net_booking_id' => $remoteId]); | ||
| } | ||
|
|
||
| public function delete(?int $remoteId): void | ||
| { | ||
| if ($remoteId === null) { | ||
| return; | ||
| } | ||
|
|
||
| $this->bookings->delete($remoteId); | ||
| } | ||
|
|
||
| private function payload(Booking $booking): ?array | ||
| { | ||
| if ($booking->type === Booking::TYPE_EVENT) { | ||
| return null; | ||
| } | ||
|
|
||
| $position = $booking->position; | ||
|
|
||
| if ($position === null || $position->isVirtual()) { | ||
| return null; | ||
| } | ||
|
|
||
| $cid = $this->resolveControllerCid($booking); | ||
|
|
||
| if ($cid === null) { | ||
| return null; | ||
| } | ||
|
|
||
| return [ | ||
| 'callsign' => $booking->ctsBooking?->position ?? $position->callsign, | ||
| 'cid' => $cid, | ||
| 'type' => $this->mapType($booking->type), | ||
| 'start' => $booking->starts_at->format('Y-m-d H:i:s'), | ||
| 'end' => $booking->ends_at->format('Y-m-d H:i:s'), | ||
| ]; | ||
| } | ||
|
|
||
| private function resolveControllerCid(Booking $booking): ?int | ||
| { | ||
| return match ($booking->type) { | ||
| Booking::TYPE_STANDARD => $booking->member_id !== null ? (int) $booking->member_id : null, | ||
| Booking::TYPE_EXAM => $this->resolveExamCid($booking), | ||
| Booking::TYPE_MENTORING => $this->resolveMentoringCid($booking), | ||
| default => null, | ||
| }; | ||
| } | ||
|
|
||
| private function resolveExamCid(Booking $booking): ?int | ||
| { | ||
| $exam = $booking->bookable; | ||
|
|
||
| if (! $exam instanceof ExamBooking) { | ||
| return null; | ||
| } | ||
|
|
||
| $account = $exam->loadMissing('examiners.primaryExaminer')->examiners?->primaryExaminer?->account; | ||
|
|
||
| return $account?->id; | ||
| } | ||
|
|
||
| private function resolveMentoringCid(Booking $booking): ?int | ||
| { | ||
| $session = $booking->bookable; | ||
|
|
||
| if (! $session instanceof Session) { | ||
| return null; | ||
| } | ||
|
|
||
| $account = $session->loadMissing('mentor')->mentor?->account; | ||
|
|
||
| return $account?->id; | ||
| } | ||
|
|
||
| private function mapType(string $type): string | ||
| { | ||
| return match ($type) { | ||
| Booking::TYPE_STANDARD => 'booking', | ||
| Booking::TYPE_EXAM => 'exam', | ||
| Booking::TYPE_MENTORING => 'mentoring', | ||
| default => 'booking', | ||
| }; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| 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('bookings', function (Blueprint $table) { | ||
| $table->unsignedBigInteger('vatsim_net_booking_id')->nullable()->after('cts_booking_id')->index(); | ||
| }); | ||
| } | ||
|
|
||
| public function down(): void | ||
| { | ||
| Schema::table('bookings', function (Blueprint $table) { | ||
| $table->dropColumn('vatsim_net_booking_id'); | ||
| }); | ||
| } | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[performance · medium]
This HTTP client makes outbound calls to VATSIM.net from a queued worker (
SyncToVatsimNet) without any timeout. Guzzle's defaulttimeout/connect_timeoutis 0 (wait indefinitely), so a stalled or unresponsive VATSIM.net endpoint can hold a Horizon worker open for an unbounded amount of time. Add explicit->timeout(...)/->connectTimeout(...)(e.g. 15s) when building the pending request.