From 42a59dd58a344734cddaf3fb8f3a114d9f29729c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 07:30:13 +0000 Subject: [PATCH 01/11] Bump react-dom from 19.2.4 to 19.2.5 Bumps [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) from 19.2.4 to 19.2.5. - [Release notes](https://github.com/facebook/react/releases) - [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md) - [Commits](https://github.com/facebook/react/commits/v19.2.5/packages/react-dom) --- updated-dependencies: - dependency-name: react-dom dependency-version: 19.2.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 10 +++++----- package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a27985f..a73ff17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,7 +48,7 @@ "next-themes": "^0.4.6", "react": "^19.2.5", "react-day-picker": "^9.13.0", - "react-dom": "^19.2.3", + "react-dom": "^19.2.5", "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", @@ -4607,15 +4607,15 @@ } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.5" } }, "node_modules/react-is": { diff --git a/package.json b/package.json index 41e523ea..07c77b5f 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "next-themes": "^0.4.6", "react": "^19.2.5", "react-day-picker": "^9.13.0", - "react-dom": "^19.2.3", + "react-dom": "^19.2.5", "recharts": "2.15.4", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", From 5fafdc0a2981bf38b61a070bab43a825a2c5ea65 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 May 2026 13:35:14 +0000 Subject: [PATCH 02/11] Bump @emnapi/core from 1.9.1 to 1.10.0 Bumps [@emnapi/core](https://github.com/toyobayashi/emnapi) from 1.9.1 to 1.10.0. - [Release notes](https://github.com/toyobayashi/emnapi/releases) - [Commits](https://github.com/toyobayashi/emnapi/compare/v1.9.1...v1.10.0) --- updated-dependencies: - dependency-name: "@emnapi/core" dependency-version: 1.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4858215e..f615ca5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,7 +5,7 @@ "packages": { "": { "dependencies": { - "@emnapi/core": "1.9.1", + "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@headlessui/react": "^2.2.9", "@inertiajs/react": "^2.3.6", @@ -246,12 +246,12 @@ "license": "MIT" }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "license": "MIT", "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, @@ -265,9 +265,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "license": "MIT", "dependencies": { "tslib": "^2.4.0" diff --git a/package.json b/package.json index 5f7c575a..5b86fefb 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "@types/ziggy-js": "^1.8.0" }, "dependencies": { - "@emnapi/core": "1.9.1", + "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", "@headlessui/react": "^2.2.9", "@inertiajs/react": "^2.3.6", From 4ed9e47461d85722a48c5e6da19e07b8751bd3aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Wed, 13 May 2026 14:51:39 +0200 Subject: [PATCH 03/11] initial changes --- .../Commands/SyncEndorsementActivities.php | 3 + app/Services/VatsimActivityService.php | 152 ++++++++++++++++++ 2 files changed, 155 insertions(+) diff --git a/app/Console/Commands/SyncEndorsementActivities.php b/app/Console/Commands/SyncEndorsementActivities.php index 68e44330..60772eea 100644 --- a/app/Console/Commands/SyncEndorsementActivities.php +++ b/app/Console/Commands/SyncEndorsementActivities.php @@ -156,6 +156,9 @@ protected function updateEndorsementActivity(EndorsementActivity $endorsementAct } } + $eligibleSince = $this->activityService->calculateEligibleSince($endorsementData); + $endorsementActivity->eligible_since = $eligibleSince; + $endorsementActivity->save(); } catch (\Exception $e) { diff --git a/app/Services/VatsimActivityService.php b/app/Services/VatsimActivityService.php index c8afef31..14df11e8 100644 --- a/app/Services/VatsimActivityService.php +++ b/app/Services/VatsimActivityService.php @@ -280,4 +280,156 @@ public function getActivityProgress(float $activityMinutes): float $minRequiredMinutes = config('services.vateud.min_activity_minutes', 180); return min(($activityMinutes / $minRequiredMinutes) * 100, 100); } + + protected function getVatsimConnectionsTwoYears(int $vatsimId): array + { + $cacheKey = "vatsim_activity_2y:{$vatsimId}"; + + return Cache::remember($cacheKey, now()->addHours(6), function () use ($vatsimId) { + $start = Carbon::now()->subYears(2)->format('Y-m-d'); + $apiUrl = "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$start}"; + + try { + $response = Http::timeout(15) + ->retry(2, 1000) + ->get($apiUrl); + + if (!$response->successful()) { + return []; + } + + $data = $response->json(); + return is_array($data) ? $data : []; + + } catch (\Exception $e) { + Log::error('Error fetching 2y VATSIM connections', [ + 'vatsim_id' => $vatsimId, + 'error' => $e->getMessage(), + ]); + return []; + } + }); + } + + protected function extractRelevantSessions(array $endorsement, array $connections): array + { + $sessions = []; + $position = $endorsement['position']; + $inTransition = Carbon::now()->lessThan(Carbon::parse($this->transitionEndDate)); + + foreach ($connections as $connection) { + $callsign = $connection['callsign'] ?? ''; + $minutes = floatval($connection['minutes_online'] ?? 0); + $date = $this->parseConnectionDate($connection); + + if (!$date || $minutes <= 0) { + continue; + } + + $matches = false; + + if (str_ends_with($position, '_CTR')) { + $ctrlPrefix = substr($position, 0, 6); + + if (str_starts_with($callsign, $ctrlPrefix) || + ($position === 'EDWW_W_CTR' && $callsign === 'EDWW_CTR')) { + $matches = true; + } + } else { + $parts = explode('_', $position); + if (count($parts) >= 2) { + $airport = $parts[0]; + $station = end($parts); + + $ctrStations = $this->ctrTopdown[$airport] ?? []; + $legacyCtrStations = $inTransition ? ($this->legacyCtrTopdown[$airport] ?? []) : []; + $appStations = $this->appTopdown[$airport] ?? []; + + $matchesSuffix = $this->suffixCondition($airport, $station, $callsign); + + $matchesCtr = false; + + $ctrAllowedStations = ['APP', 'TWR', 'GNDDEL']; + + if (in_array($station, $ctrAllowedStations, true)) { + $allCtrStations = array_unique(array_merge($ctrStations, $legacyCtrStations)); + + foreach ($allCtrStations as $ctrStation) { + if (str_starts_with($callsign, $ctrStation)) { + $matchesCtr = true; + break; + } + } + + if (!$matchesCtr && $station !== 'APP') { + foreach ($appStations as $appStation) { + if (str_starts_with($callsign, $appStation)) { + $matchesCtr = true; + break; + } + } + } + } + + if (!$matchesCtr && $station === 'APP') { + foreach ($appStations as $appStation) { + if (str_starts_with($callsign, $appStation)) { + $matchesCtr = true; + break; + } + } + } + + $matches = $matchesCtr || $matchesSuffix; + } + } + + if ($matches) { + $sessions[] = [ + 'date' => $date, + 'minutes' => $minutes, + ]; + } + } + + usort($sessions, fn($a, $b) => $a['date']->lt($b['date']) ? -1 : 1); + + return $sessions; + } + + public function calculateEligibleSince(array $endorsement): ?Carbon + { + $connections = $this->getVatsimConnectionsTwoYears($endorsement['user_cid']); + $sessions = $this->extractRelevantSessions($endorsement, $connections); + + // 🔴 Requirement: no sessions in 2 years → NULL + if (empty($sessions)) { + return null; + } + + $left = 0; + $totalMinutes = 0; + $lastValidDate = null; + + for ($right = 0; $right < count($sessions); $right++) { + $totalMinutes += $sessions[$right]['minutes']; + + while ( + $sessions[$right]['date']->diffInDays($sessions[$left]['date']) > 180 + ) { + $totalMinutes -= $sessions[$left]['minutes']; + $left++; + } + + if ($totalMinutes >= 180) { + $lastValidDate = $sessions[$right]['date']; + } + } + + if ($lastValidDate === null) { + return $sessions[0]['date']->copy()->addDays(180); + } + + return $lastValidDate->copy()->addDays(180); + } } From 1786616d972a18353e8fa9f5569297e42935a238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Wed, 13 May 2026 15:24:08 +0200 Subject: [PATCH 04/11] low activity calculation added --- .../Controllers/EndorsementController.php | 2 + app/Models/EndorsementActivity.php | 2 + app/Services/VatsimActivityService.php | 51 +++++++++++++------ ...igible_since_to_endorsement_activities.php | 30 +++++++++++ resources/js/pages/endorsements/manage.tsx | 19 +++++++ 5 files changed, 89 insertions(+), 15 deletions(-) create mode 100644 database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php diff --git a/app/Http/Controllers/EndorsementController.php b/app/Http/Controllers/EndorsementController.php index 839511ee..ccb7db7b 100644 --- a/app/Http/Controllers/EndorsementController.php +++ b/app/Http/Controllers/EndorsementController.php @@ -198,6 +198,7 @@ public function mentorView(Request $request): Response 'activityHours' => $activity->activity_hours, 'status' => $activity->status, 'progress' => $activity->progress, + 'eligibleSince' => $activity->eligible_since, 'removalDate' => $activity->removal_date?->format('Y-m-d'), 'removalDays' => $activity->removal_date ? $activity->removal_date->diffInDays(now(), false) @@ -305,6 +306,7 @@ public function mentorView(Request $request): Response 'userPermissions' => [ 'canRemoveForPositions' => $canRemovePositions, 'canRemoveAny' => ($user->is_superuser || $user->is_admin) || (!empty($canRemovePositions) && count($canRemovePositions) > 0), + 'isAdmin' => $user->is_superuser || $user->is_admin, ], ]); } diff --git a/app/Models/EndorsementActivity.php b/app/Models/EndorsementActivity.php index 3ec79b10..48d3c441 100644 --- a/app/Models/EndorsementActivity.php +++ b/app/Models/EndorsementActivity.php @@ -18,6 +18,7 @@ class EndorsementActivity extends Model 'last_updated', 'last_activity_date', 'removal_date', + 'eligible_since', 'removal_notified', 'created_at_vateud', ]; @@ -27,6 +28,7 @@ class EndorsementActivity extends Model 'last_updated' => 'datetime', 'last_activity_date' => 'date', 'removal_date' => 'date', + 'eligible_since' => 'datetime', 'removal_notified' => 'boolean', 'created_at_vateud' => 'datetime', ]; diff --git a/app/Services/VatsimActivityService.php b/app/Services/VatsimActivityService.php index 14df11e8..380bc6ea 100644 --- a/app/Services/VatsimActivityService.php +++ b/app/Services/VatsimActivityService.php @@ -402,34 +402,55 @@ public function calculateEligibleSince(array $endorsement): ?Carbon $connections = $this->getVatsimConnectionsTwoYears($endorsement['user_cid']); $sessions = $this->extractRelevantSessions($endorsement, $connections); - // 🔴 Requirement: no sessions in 2 years → NULL + // No sessions in 2 years -> no value if (empty($sessions)) { return null; } - $left = 0; - $totalMinutes = 0; - $lastValidDate = null; + $requiredMinutes = config('services.vateud.min_activity_minutes', 180); - for ($right = 0; $right < count($sessions); $right++) { - $totalMinutes += $sessions[$right]['minutes']; + $events = []; - while ( - $sessions[$right]['date']->diffInDays($sessions[$left]['date']) > 180 + foreach ($sessions as $session) { + $events[] = [ + 'date' => $session['date']->copy(), + 'delta' => $session['minutes'], + ]; + + $events[] = [ + 'date' => $session['date']->copy()->addDays(180), + 'delta' => -$session['minutes'], + ]; + } + + usort($events, function ($a, $b) { + return $a['date']->timestamp <=> $b['date']->timestamp; + }); + + $runningMinutes = 0; + $eligibleSince = null; + + foreach ($events as $event) { + $before = $runningMinutes; + + $runningMinutes += $event['delta']; + + if ( + $before >= $requiredMinutes && + $runningMinutes < $requiredMinutes ) { - $totalMinutes -= $sessions[$left]['minutes']; - $left++; + $eligibleSince = $event['date']->copy(); } - if ($totalMinutes >= 180) { - $lastValidDate = $sessions[$right]['date']; + if ($runningMinutes >= $requiredMinutes) { + $eligibleSince = null; } } - if ($lastValidDate === null) { - return $sessions[0]['date']->copy()->addDays(180); + if ($runningMinutes < $requiredMinutes) { + return $eligibleSince; } - return $lastValidDate->copy()->addDays(180); + return null; } } diff --git a/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php b/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php new file mode 100644 index 00000000..9f861982 --- /dev/null +++ b/database/migrations/2026_05_13_125344_add_eligible_since_to_endorsement_activities.php @@ -0,0 +1,30 @@ +timestamp('eligible_since') + ->nullable() + ->after('removal_date'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('endorsement_activities', function (Blueprint $table) { + $table->dropColumn('eligible_since'); + }); + } +}; \ No newline at end of file diff --git a/resources/js/pages/endorsements/manage.tsx b/resources/js/pages/endorsements/manage.tsx index b3d89697..be725a86 100644 --- a/resources/js/pages/endorsements/manage.tsx +++ b/resources/js/pages/endorsements/manage.tsx @@ -57,6 +57,7 @@ interface EndorsementData { activityHours: number status: "active" | "warning" | "removal" progress: number + eligibleSince: string removalDate: string | null removalDays: number } @@ -74,6 +75,7 @@ interface PageProps { userPermissions: { canRemoveForPositions: string[] | null canRemoveAny: boolean + isAdmin: boolean } } @@ -122,6 +124,8 @@ export default function ManageEndorsements({ const [isProcessing, setIsProcessing] = useState(false) const [showActiveEndorsements, setShowActiveEndorsements] = useState(false) + const { isAdmin } = userPermissions + const canRemoveForPosition = (position: string): boolean => { if (userPermissions.canRemoveForPositions === null) { return true @@ -478,6 +482,7 @@ export default function ManageEndorsements({ Controller Activity Status + {isAdmin && Eligible Since} Actions @@ -564,6 +569,20 @@ export default function ManageEndorsements({ )} + {isAdmin && ( + + {state !== "active" + ? (() => { + const date = new Date( + endorsement.eligibleSince, + ) + return date.getFullYear() === 1970 + ? "Unknown" + : date.toLocaleDateString("de") + })() + : null} + + )} From 7ad51576d4b5dd22ec531c0ddb9039d10e06e8ac Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:29:05 +0000 Subject: [PATCH 05/11] Bump @headlessui/react from 2.2.9 to 2.2.10 Bumps [@headlessui/react](https://github.com/tailwindlabs/headlessui/tree/HEAD/packages/@headlessui-react) from 2.2.9 to 2.2.10. - [Release notes](https://github.com/tailwindlabs/headlessui/releases) - [Changelog](https://github.com/tailwindlabs/headlessui/blob/main/packages/@headlessui-react/CHANGELOG.md) - [Commits](https://github.com/tailwindlabs/headlessui/commits/@headlessui/react@v2.2.10/packages/@headlessui-react) --- updated-dependencies: - dependency-name: "@headlessui/react" dependency-version: 2.2.10 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 48561fad..e165aaff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", - "@headlessui/react": "^2.2.9", + "@headlessui/react": "^2.2.10", "@inertiajs/react": "^2.3.6", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -327,9 +327,9 @@ "license": "MIT" }, "node_modules/@headlessui/react": { - "version": "2.2.9", - "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.9.tgz", - "integrity": "sha512-Mb+Un58gwBn0/yWZfyrCh0TJyurtT+dETj7YHleylHk5od3dv2XqETPGWMyQ5/7sYN7oWdyM1u9MvC0OC8UmzQ==", + "version": "2.2.10", + "resolved": "https://registry.npmjs.org/@headlessui/react/-/react-2.2.10.tgz", + "integrity": "sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==", "license": "MIT", "dependencies": { "@floating-ui/react": "^0.26.16", diff --git a/package.json b/package.json index 34026d8a..fb03ae38 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "dependencies": { "@emnapi/core": "1.10.0", "@emnapi/runtime": "1.10.0", - "@headlessui/react": "^2.2.9", + "@headlessui/react": "^2.2.10", "@inertiajs/react": "^2.3.6", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", From a9d2788883c80f76275ce1631e858d10d57efcaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Sat, 16 May 2026 14:14:57 +0200 Subject: [PATCH 06/11] bug: fixed roster calculation logic --- app/Console/Commands/CheckRosterStatus.php | 236 ++++++++------------- app/Services/VatEudService.php | 18 +- 2 files changed, 111 insertions(+), 143 deletions(-) diff --git a/app/Console/Commands/CheckRosterStatus.php b/app/Console/Commands/CheckRosterStatus.php index cb9bc5fa..c74f2b7d 100644 --- a/app/Console/Commands/CheckRosterStatus.php +++ b/app/Console/Commands/CheckRosterStatus.php @@ -56,138 +56,112 @@ public function handle(): int } } - protected function getRoster(): array - { - try { - $response = Http::withHeaders([ - 'X-API-KEY' => config('services.vateud.token'), - 'Accept' => 'application/json', - 'User-Agent' => 'VATGER Training System', - ])->get('https://core.vateud.net/api/facility/roster'); - - if ($response->successful()) { - $data = $response->json(); - return $data['data']['controllers'] ?? []; - } - } catch (\Exception $e) { - Log::error('Failed to fetch roster from VatEUD', ['error' => $e->getMessage()]); - } - - return []; - } - protected function checkUser(int $vatsimId): void { try { $entry = RosterEntry::firstOrCreate( ['user_id' => $vatsimId], - [ - 'last_session' => Carbon::createFromTimestamp(0), - 'removal_date' => null - ] + ['last_session' => null, 'removal_date' => null] ); - if ($entry->last_session && !$entry->last_session->timezone) { - $entry->last_session = Carbon::parse($entry->last_session)->timezone('UTC'); - $entry->save(); + try { + $lastSession = $this->getLastSession($vatsimId); + + if ($lastSession instanceof Carbon && $lastSession->year > 2000) { + $entry->last_session = $lastSession; + $entry->save(); + } + } catch (\Exception $e) { + Log::warning('Session fetch failed', [ + 'vatsim_id' => $vatsimId, + 'error' => $e->getMessage(), + ]); } - if ($entry->last_session && now()->diffInDays($entry->last_session) < (11 * 30)) { + if (!$entry->last_session) { return; } - try { - $lastSession = $this->getLastSession($vatsimId); - $entry->last_session = $lastSession; + $inactiveDays = $entry->last_session->diffInDays(now()); + + $WARNING_THRESHOLD = 330; // ~11 months + $REMOVAL_THRESHOLD = 365; // 12 months + $GRACE_DAYS = 35; + + + if ($inactiveDays >= $WARNING_THRESHOLD && !$entry->removal_date) { + + $this->sendRemovalWarning($vatsimId); + + $entry->removal_date = now()->addDays($GRACE_DAYS); $entry->save(); - } catch (\Exception $e) { - $this->warn("Error getting last session for {$vatsimId}: " . $e->getMessage()); + return; } - if ($entry->last_session->lt(now()->subDays(366))) { - if ($entry->removal_date && $entry->removal_date->lt(now())) { - $this->removeFromRoster($vatsimId); - $entry->delete(); - return; - } - } + if ( + $inactiveDays >= $REMOVAL_THRESHOLD && + $entry->removal_date && + now()->gte($entry->removal_date) + ) { - if ($entry->last_session->lt(now()->subDays(11 * 30))) { - try { - [$isRecentS1, $ratingChangeDate] = $this->checkS1Status($vatsimId); - - if ($isRecentS1) { - $entry->last_session = $ratingChangeDate; - $entry->removal_date = null; - $entry->save(); - return; - } - } catch (\Exception $e) { - $this->warn("Error checking rating for {$vatsimId}: " . $e->getMessage()); - } + $this->removeFromRoster($vatsimId); + $entry->delete(); - if (!$entry->removal_date) { - $this->sendRemovalWarning($vatsimId); - $entry->removal_date = now()->addDays(35); - $entry->save(); - $this->info("Set removal date for {$vatsimId}: " . $entry->removal_date->format('Y-m-d')); - } - } else { - if ($entry->removal_date) { - $entry->removal_date = null; - $entry->save(); - } + return; } - } catch (\Exception $e) { - $this->error("Error checking user {$vatsimId}: " . $e->getMessage()); - Log::error('Error in roster check for user', [ + } catch (\Throwable $e) { + Log::error('USER CHECK FAILED', [ 'vatsim_id' => $vatsimId, - 'error' => $e->getMessage() + 'error' => $e->getMessage(), ]); } } + protected function getRoster(): array + { + $response = Http::withHeaders([ + 'X-API-KEY' => config('services.vateud.token'), + 'Accept' => 'application/json', + ])->get('https://core.vateud.net/api/facility/roster'); + + return $response->successful() + ? ($response->json()['data']['controllers'] ?? []) + : []; + } + protected function getLastSession(int $vatsimId): Carbon { - $date = now()->subDays(365); - $apiUrl = "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$date->format('Y-m-d')}"; + $date = now()->subYear(); - try { - $response = Http::timeout(15)->get($apiUrl); - - if (!$response->successful()) { - throw new \Exception("API request failed with status: " . $response->status()); - } + $response = Http::timeout(15)->get( + "http://stats.vatsim-germany.org/api/atc/{$vatsimId}/sessions/?start_date={$date->format('Y-m-d')}" + ); - $connections = $response->json(); - if (!is_array($connections)) { - $connections = []; - } - - foreach ($connections as $connection) { - $callsign = $connection['callsign'] ?? ''; - $prefix = substr($callsign, 0, 2); - - if (in_array($prefix, ['ED', 'ET'])) { - $endTime = $connection['disconnected_at'] ?? null; - if ($endTime) { - return Carbon::parse($endTime)->timezone('UTC'); - } - } + if (!$response->successful()) { + throw new \Exception('Session API failed'); + } + + $latest = null; + + foreach ($response->json() as $session) { + $prefix = substr($session['callsign'] ?? '', 0, 2); + + if (!in_array($prefix, ['ED', 'ET'])) { + continue; } - $this->warn("No German connections found for user {$vatsimId}"); - return Carbon::createFromTimestamp(0)->timezone('UTC'); + if (!empty($session['disconnected_at'])) { + $time = Carbon::parse($session['disconnected_at']); - } catch (\Exception $e) { - Log::error('Error fetching last session from vatsim-germany.org', [ - 'vatsim_id' => $vatsimId, - 'error' => $e->getMessage() - ]); - throw $e; + if (!$latest || $time->gt($latest)) { + $latest = $time; + } + } } + + return $latest ?? Carbon::createFromTimestamp(0); } protected function checkS1Status(int $vatsimId): array @@ -283,52 +257,30 @@ protected function sendRemovalWarning(int $vatsimId): void protected function removeFromRoster(int $vatsimId): void { - try { - $this->info("Removing user {$vatsimId} from roster..."); - - $user = User::where('vatsim_id', $vatsimId)->first(); - - $this->vatEudService->removeRosterAndEndorsements($vatsimId); - - WaitingListEntry::whereHas('user', function ($query) use ($vatsimId) { - $query->where('vatsim_id', $vatsimId); - })->delete(); - - if ($user) { - ActivityLogger::log( - 'roster.removed', - $user, - "User {$user->name} (VATSIM ID: {$vatsimId}) removed from roster due to inactivity", - [ - 'vatsim_id' => $vatsimId, - 'user_id' => $user->id, - 'user_name' => $user->name, - 'reason' => 'inactivity', - 'removed_by' => 'system', - ] - ); - } else { - ActivityLogger::log( - 'roster.removed', - null, - "VATSIM ID {$vatsimId} removed from roster due to inactivity", - [ - 'vatsim_id' => $vatsimId, - 'reason' => 'inactivity', - 'removed_by' => 'system', - ] - ); - } + $success = $this->vatEudService->removeRosterAndEndorsements($vatsimId); - Log::info('User removed from roster', [ - 'vatsim_id' => $vatsimId - ]); - - } catch (\Exception $e) { - Log::error('Error removing user from roster', [ + if (!$success) { + Log::error('Roster removal failed at VATEUD', [ 'vatsim_id' => $vatsimId, - 'error' => $e->getMessage() ]); + return; } + + WaitingListEntry::whereHas('user', function ($q) use ($vatsimId) { + $q->where('vatsim_id', $vatsimId); + })->delete(); + + ActivityLogger::log( + 'roster.removed', + null, + "User {$vatsimId} removed due to inactivity", + [ + 'vatsim_id' => $vatsimId, + 'reason' => 'inactivity', + 'removed_by' => 'system', + ] + ); + + Log::warning("REMOVAL COMPLETE: {$vatsimId}"); } } diff --git a/app/Services/VatEudService.php b/app/Services/VatEudService.php index 950591e3..eb388a71 100644 --- a/app/Services/VatEudService.php +++ b/app/Services/VatEudService.php @@ -243,9 +243,19 @@ public function removeRosterAndEndorsements(int $vatsimId): bool $rosterResponse = Http::withHeaders($this->headers) ->delete("{$this->baseUrl}/facility/roster/{$vatsimId}"); + if (!$rosterResponse->successful()) { + Log::error('Roster removal failed', [ + 'vatsim_id' => $vatsimId, + 'status' => $rosterResponse->status(), + 'body' => $rosterResponse->body(), + ]); + + return false; + } + $tier1 = collect($this->getTier1Endorsements()) ->where('user_cid', $vatsimId); - + $tier2 = collect($this->getTier2Endorsements()) ->where('user_cid', $vatsimId); @@ -259,12 +269,18 @@ public function removeRosterAndEndorsements(int $vatsimId): bool ->delete("{$this->baseUrl}/facility/endorsements/tier-2/{$endorsement['id']}"); } + Log::warning('ROSTER REMOVAL COMPLETED', [ + 'vatsim_id' => $vatsimId, + ]); + return true; + } catch (\Exception $e) { Log::error('Error removing roster and endorsements', [ 'vatsim_id' => $vatsimId, 'error' => $e->getMessage() ]); + return false; } } From 90dcff04b2c1d40a8dc4370f7be3ca730904905c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Sat, 16 May 2026 14:19:03 +0200 Subject: [PATCH 07/11] added endorsement removal error handling --- app/Services/VatEudService.php | 56 +++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 7 deletions(-) diff --git a/app/Services/VatEudService.php b/app/Services/VatEudService.php index eb388a71..5967e627 100644 --- a/app/Services/VatEudService.php +++ b/app/Services/VatEudService.php @@ -256,17 +256,59 @@ public function removeRosterAndEndorsements(int $vatsimId): bool $tier1 = collect($this->getTier1Endorsements()) ->where('user_cid', $vatsimId); - $tier2 = collect($this->getTier2Endorsements()) - ->where('user_cid', $vatsimId); - foreach ($tier1 as $endorsement) { - Http::withHeaders($this->headers) - ->delete("{$this->baseUrl}/facility/endorsements/tier-1/{$endorsement['id']}"); + try { + $response = Http::withHeaders($this->headers) + ->delete("{$this->baseUrl}/facility/endorsements/tier-1/{$endorsement['id']}"); + + if (!$response->successful()) { + Log::warning('Failed to delete Tier 1 endorsement', [ + 'vatsim_id' => $vatsimId, + 'endorsement_id' => $endorsement['id'], + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + $success = false; + } + } catch (\Throwable $e) { + Log::error('Exception while deleting Tier 1 endorsement', [ + 'vatsim_id' => $vatsimId, + 'endorsement_id' => $endorsement['id'], + 'error' => $e->getMessage(), + ]); + + $success = false; + } } + $tier2 = collect($this->getTier2Endorsements()) + ->where('user_cid', $vatsimId); + foreach ($tier2 as $endorsement) { - Http::withHeaders($this->headers) - ->delete("{$this->baseUrl}/facility/endorsements/tier-2/{$endorsement['id']}"); + try { + $response = Http::withHeaders($this->headers) + ->delete("{$this->baseUrl}/facility/endorsements/tier-2/{$endorsement['id']}"); + + if (!$response->successful()) { + Log::warning('Failed to delete Tier 2 endorsement', [ + 'vatsim_id' => $vatsimId, + 'endorsement_id' => $endorsement['id'], + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + $success = false; + } + } catch (\Throwable $e) { + Log::error('Exception while deleting Tier 2 endorsement', [ + 'vatsim_id' => $vatsimId, + 'endorsement_id' => $endorsement['id'], + 'error' => $e->getMessage(), + ]); + + $success = false; + } } Log::warning('ROSTER REMOVAL COMPLETED', [ From 8f20fbfb14c2319aa7a5c33d03537ead22b22d5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Sat, 16 May 2026 14:37:13 +0200 Subject: [PATCH 08/11] removal warning notification adjusted --- app/Console/Commands/CheckRosterStatus.php | 51 +++++++--------------- 1 file changed, 16 insertions(+), 35 deletions(-) diff --git a/app/Console/Commands/CheckRosterStatus.php b/app/Console/Commands/CheckRosterStatus.php index c74f2b7d..a72c4221 100644 --- a/app/Console/Commands/CheckRosterStatus.php +++ b/app/Console/Commands/CheckRosterStatus.php @@ -214,43 +214,24 @@ protected function checkS1Status(int $vatsimId): array } protected function sendRemovalWarning(int $vatsimId): void - { - $apiKey = config('services.vatger.api_key'); - $apiBaseUrl = config('services.vatger.api_url'); - - if (!$apiKey) { - Log::warning('VATGER API key not configured, skipping removal notification'); - return; - } - - $message = "You have not controlled in the past 11 months. " . - "If you want to stay on the VATSIM Germany roster, " . - "please log in to the VATSIM network and control at least once in the next 35 days. " . - "If you do not, your account will be removed from the roster. " . - "If you believe this is a mistake, please contact the ATD."; - - $data = [ - 'title' => 'Removal from VATSIM Germany Roster', - 'message' => $message, - 'source_name' => 'VATGER ATD', - 'via' => 'board.ping', - ]; - - try { - $response = Http::withHeaders([ - 'Authorization' => "Token {$apiKey}", - ])->post("{$apiBaseUrl}/user/{$vatsimId}/send_notification", $data); + { + $message = + "You have not controlled in the past 11 months. " . + "To remain on the VATSIM Germany roster, please log in and control within the next 35 days. " . + "Otherwise, your account will be removed from the roster. " . + "If you believe this is a mistake, please contact the ATD."; + + $result = $this->vatgerService->sendNotification( + $vatsimId, + 'Removal from VATSIM Germany Roster', + $message, + 'VATGER ATD', + 'https://vatsim-germany.org' + ); - if (!$response->successful()) { - Log::warning('Failed to send removal notification', [ - 'vatsim_id' => $vatsimId, - 'status' => $response->status() - ]); - } - } catch (\Exception $e) { - Log::error('Error sending removal notification', [ + if (!$result['success']) { + Log::error('Failed to send roster removal warning', [ 'vatsim_id' => $vatsimId, - 'error' => $e->getMessage() ]); } } From 44b1c22d69b3488772631831649109aa9aa4f41f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Sat, 16 May 2026 14:40:22 +0200 Subject: [PATCH 09/11] logging roster notification --- app/Console/Commands/CheckRosterStatus.php | 8 +++++++- app/Enums/ActivityAction.php | 3 +++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/Console/Commands/CheckRosterStatus.php b/app/Console/Commands/CheckRosterStatus.php index a72c4221..7eb4da90 100644 --- a/app/Console/Commands/CheckRosterStatus.php +++ b/app/Console/Commands/CheckRosterStatus.php @@ -233,6 +233,12 @@ protected function sendRemovalWarning(int $vatsimId): void Log::error('Failed to send roster removal warning', [ 'vatsim_id' => $vatsimId, ]); + } else { + ActivityLogger::log( + 'roster.notified', + $vatsimId, + "Notified roster removal for $vatsimId", + ); } } @@ -254,7 +260,7 @@ protected function removeFromRoster(int $vatsimId): void ActivityLogger::log( 'roster.removed', null, - "User {$vatsimId} removed due to inactivity", + "User {$vatsimId} removed from roster due to inactivity", [ 'vatsim_id' => $vatsimId, 'reason' => 'inactivity', diff --git a/app/Enums/ActivityAction.php b/app/Enums/ActivityAction.php index 77d02c92..6680948e 100644 --- a/app/Enums/ActivityAction.php +++ b/app/Enums/ActivityAction.php @@ -61,6 +61,7 @@ enum ActivityAction: string case CPT_UPDATED = 'cpt.updated'; case ROSTER_REMOVED = 'roster.removed'; + case ROSTER_NOTIFIED = 'roster.notified'; case GDPR_DELETION = 'gdpr.deletion'; case API_USER_RETRIEVAL = 'api.user.retrieval'; @@ -129,6 +130,7 @@ public function getLabel(): string self::CPT_UPDATED => 'CPT Updated', self::ROSTER_REMOVED => 'Removed from Roster', + self::ROSTER_NOTIFIED => 'Notified Roster Removal', self::GDPR_DELETION => 'GDPR User Deletion', @@ -180,6 +182,7 @@ public function getColor(): string self::COURSE_FINISHED, self::COURSE_UPDATED, self::COT_UPDATED, + self::ROSTER_NOTIFIED, self::UPDATED => 'warning', self::CPT_EXAMINER_JOINED, From cbbe972797b7d8089f5958e8e2e8439afceb9add Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ben=20B=C3=B6ckmann?= Date: Sat, 16 May 2026 14:58:34 +0200 Subject: [PATCH 10/11] fixed removal date resetting --- app/Console/Commands/CheckRosterStatus.php | 110 +++++++++++++-------- 1 file changed, 67 insertions(+), 43 deletions(-) diff --git a/app/Console/Commands/CheckRosterStatus.php b/app/Console/Commands/CheckRosterStatus.php index 7eb4da90..dc507afd 100644 --- a/app/Console/Commands/CheckRosterStatus.php +++ b/app/Console/Commands/CheckRosterStatus.php @@ -57,67 +57,91 @@ public function handle(): int } protected function checkUser(int $vatsimId): void - { - try { - $entry = RosterEntry::firstOrCreate( - ['user_id' => $vatsimId], - ['last_session' => null, 'removal_date' => null] - ); - - try { - $lastSession = $this->getLastSession($vatsimId); +{ + try { + $entry = RosterEntry::firstOrCreate( + ['user_id' => $vatsimId], + [ + 'last_session' => null, + 'removal_date' => null, + ] + ); - if ($lastSession instanceof Carbon && $lastSession->year > 2000) { - $entry->last_session = $lastSession; - $entry->save(); - } - } catch (\Exception $e) { - Log::warning('Session fetch failed', [ - 'vatsim_id' => $vatsimId, - 'error' => $e->getMessage(), - ]); - } + try { + $lastSession = $this->getLastSession($vatsimId); - if (!$entry->last_session) { - return; + if ($lastSession instanceof Carbon && $lastSession->year > 2000) { + $entry->last_session = $lastSession; + $entry->save(); } + } catch (\Throwable $e) { + Log::warning('Failed fetching last session', [ + 'vatsim_id' => $vatsimId, + 'error' => $e->getMessage(), + ]); + } - $inactiveDays = $entry->last_session->diffInDays(now()); + if (!$entry->last_session) { + return; + } - $WARNING_THRESHOLD = 330; // ~11 months - $REMOVAL_THRESHOLD = 365; // 12 months - $GRACE_DAYS = 35; + $inactiveDays = $entry->last_session->diffInDays(now()); + $WARNING_THRESHOLD = 330; // ~11 months + $REMOVAL_THRESHOLD = 365; // 12 months + $GRACE_DAYS = 35; - if ($inactiveDays >= $WARNING_THRESHOLD && !$entry->removal_date) { + if ($inactiveDays < $WARNING_THRESHOLD) { - $this->sendRemovalWarning($vatsimId); + if ($entry->removal_date) { + Log::info('USER RECOVERED ROSTER ACTIVITY - resetting warning', [ + 'vatsim_id' => $vatsimId, + ]); - $entry->removal_date = now()->addDays($GRACE_DAYS); + $entry->removal_date = null; $entry->save(); - - return; } - if ( - $inactiveDays >= $REMOVAL_THRESHOLD && - $entry->removal_date && - now()->gte($entry->removal_date) - ) { + return; + } - $this->removeFromRoster($vatsimId); - $entry->delete(); + if ($inactiveDays >= $WARNING_THRESHOLD && !$entry->removal_date) { - return; - } + Log::warning('SENDING ROSTER REMOVAL WARNING', [ + 'vatsim_id' => $vatsimId, + ]); - } catch (\Throwable $e) { - Log::error('USER CHECK FAILED', [ + $this->sendRemovalWarning($vatsimId); + + $entry->removal_date = now()->addDays($GRACE_DAYS); + $entry->save(); + + return; + } + + if ( + $inactiveDays >= $REMOVAL_THRESHOLD && + $entry->removal_date && + now()->gte($entry->removal_date) + ) { + Log::warning('REMOVING USER FROM ROSTER', [ 'vatsim_id' => $vatsimId, - 'error' => $e->getMessage(), + 'inactive_days' => $inactiveDays, + 'removal_date' => $entry->removal_date, ]); + + $this->removeFromRoster($vatsimId); + $entry->delete(); + + return; } + } catch (\Throwable $e) { + Log::error('ROSTER CHECK FAILED', [ + 'vatsim_id' => $vatsimId, + 'error' => $e->getMessage(), + ]); } +} protected function getRoster(): array { @@ -268,6 +292,6 @@ protected function removeFromRoster(int $vatsimId): void ] ); - Log::warning("REMOVAL COMPLETE: {$vatsimId}"); + Log::warning("ROSTER REMOVAL COMPLETE: {$vatsimId}"); } } From 435392254282f926d2f17eb77dd4c8d4f9480485 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 10:34:56 +0000 Subject: [PATCH 11/11] Bump lucide-react from 1.14.0 to 1.16.0 Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 1.14.0 to 1.16.0. - [Release notes](https://github.com/lucide-icons/lucide/releases) - [Commits](https://github.com/lucide-icons/lucide/commits/1.16.0/packages/lucide-react) --- updated-dependencies: - dependency-name: lucide-react dependency-version: 1.16.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e165aaff..dde6d38f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "date-fns": "^4.1.0", "globals": "^17.5.0", "laravel-vite-plugin": "^3.1.0", - "lucide-react": "^1.14.0", + "lucide-react": "^1.16.0", "next-themes": "^0.4.6", "react": "^19.2.5", "react-day-picker": "^9.13.0", @@ -4251,9 +4251,9 @@ } }, "node_modules/lucide-react": { - "version": "1.14.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", - "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz", + "integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index fb03ae38..44f40e7e 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "date-fns": "^4.1.0", "globals": "^17.5.0", "laravel-vite-plugin": "^3.1.0", - "lucide-react": "^1.14.0", + "lucide-react": "^1.16.0", "next-themes": "^0.4.6", "react": "^19.2.5", "react-day-picker": "^9.13.0",